diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..372b6f6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +* @33Fraise33 + +/.github/** @33Fraise33 +/.opencode/** @33Fraise33 +/AGENTS.md @33Fraise33 +/opencode.json @33Fraise33 diff --git a/.github/scripts/opencode-auth.mjs b/.github/scripts/opencode-auth.mjs new file mode 100644 index 0000000..f1b7055 --- /dev/null +++ b/.github/scripts/opencode-auth.mjs @@ -0,0 +1,48 @@ +import { appendFileSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { pathToFileURL } from "node:url" + +const COMPONENTS = [ + "OPENAI_OAUTH_ACCESS_TOKEN", + "OPENAI_OAUTH_REFRESH_TOKEN", + "OPENAI_OAUTH_EXPIRES", + "OPENAI_OAUTH_ACCOUNT_ID", +] + +export function buildAuthContent(env) { + for (const name of COMPONENTS) { + if (typeof env[name] !== "string" || env[name].length === 0) { + throw new Error(`${name} is required.`) + } + } + + if (!/^\d+$/.test(env.OPENAI_OAUTH_EXPIRES)) { + throw new Error("OPENAI_OAUTH_EXPIRES must be a non-negative safe integer.") + } + const expires = Number(env.OPENAI_OAUTH_EXPIRES) + if (!Number.isSafeInteger(expires)) { + throw new Error("OPENAI_OAUTH_EXPIRES must be a non-negative safe integer.") + } + + return JSON.stringify({ + openai: { + type: "oauth", + refresh: env.OPENAI_OAUTH_REFRESH_TOKEN, + access: env.OPENAI_OAUTH_ACCESS_TOKEN, + expires, + accountId: env.OPENAI_OAUTH_ACCOUNT_ID, + }, + }) +} + +export function main(env = process.env, log = console.log) { + if (!env.GITHUB_ENV) throw new Error("GITHUB_ENV is required.") + const content = buildAuthContent(env) + const delimiter = `opencode_auth_${randomUUID()}` + log(`::add-mask::${content}`) + appendFileSync(env.GITHUB_ENV, `OPENCODE_AUTH_CONTENT<<${delimiter}\n${content}\n${delimiter}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/.github/scripts/opencode-auth.test.mjs b/.github/scripts/opencode-auth.test.mjs new file mode 100644 index 0000000..e6e6b79 --- /dev/null +++ b/.github/scripts/opencode-auth.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import test from "node:test" + +import { buildAuthContent, main } from "./opencode-auth.mjs" + +const components = { + OPENAI_OAUTH_ACCESS_TOKEN: "access-value", + OPENAI_OAUTH_REFRESH_TOKEN: "refresh-value", + OPENAI_OAUTH_EXPIRES: "123", + OPENAI_OAUTH_ACCOUNT_ID: "account-value", +} + +test("constructs the exact OpenCode OAuth JSON", () => { + assert.deepEqual(JSON.parse(buildAuthContent(components)), { + openai: { + type: "oauth", + refresh: "refresh-value", + access: "access-value", + expires: 123, + accountId: "account-value", + }, + }) +}) + +test("requires every component and a non-negative safe integer expiration", () => { + for (const name of Object.keys(components)) { + assert.throws(() => buildAuthContent({ ...components, [name]: "" }), new RegExp(name)) + } + for (const expires of ["-1", "1.2", "nope", "9007199254740992"]) { + assert.throws(() => buildAuthContent({ ...components, OPENAI_OAUTH_EXPIRES: expires }), /safe integer/) + } +}) + +test("masks JSON before writing it with a random delimiter", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-auth-")) + const envPath = path.join(directory, "env") + const logs = [] + try { + main({ ...components, GITHUB_ENV: envPath }, (line) => logs.push(line)) + const content = buildAuthContent(components) + assert.deepEqual(logs, [`::add-mask::${content}`]) + assert.match(await readFile(envPath, "utf8"), /^OPENCODE_AUTH_CONTENT< ({ + ok: true, + status: 200, + json: async () => { + assert.equal(url, `https://api.github.test/repos/owner/repo/collaborators/${username}/permission`) + return { permission } + }, + }) +} + +test("authorizes only repository write and admin access", async () => { + await assert.doesNotReject(() => authorizeActor(event, { ...options, fetchImpl: response("write") })) + await assert.doesNotReject(() => authorizeActor(event, { ...options, fetchImpl: response("admin") })) + await assert.rejects(() => authorizeActor(event, { ...options, fetchImpl: response("read") }), /write or admin/) +}) + +test("limits plan mode to the repository owner", async () => { + await assert.rejects( + () => authorizeActor(event, { ...options, mode: "plan", repositoryOwner: "owner", fetchImpl: response("write") }), + /repository owner/, + ) + await assert.doesNotReject( + () => authorizeActor({ comment: { user: { login: "owner" } } }, { ...options, mode: "plan", fetchImpl: response("write", "owner") }), + ) +}) diff --git a/.github/scripts/opencode-command.mjs b/.github/scripts/opencode-command.mjs new file mode 100644 index 0000000..049359e --- /dev/null +++ b/.github/scripts/opencode-command.mjs @@ -0,0 +1,85 @@ +import { appendFileSync, readFileSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { pathToFileURL } from "node:url" + +export const MAX_PROMPT_LENGTH = 8000 +const GITHUB_ATTACHMENT_URL = /https:\/\/github\.com\/user-attachments\//i + +const MODELS = Object.freeze({ + sol: "openai/gpt-5.6-sol", + terra: "openai/gpt-5.6-terra", + luna: "openai/gpt-5.6-luna", +}) + +const DEFAULT_MODELS = Object.freeze({ + plan: MODELS.sol, + build: MODELS.terra, +}) + +const ALLOWED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]) + +export function isAllowedAssociation(association) { + return ALLOWED_ASSOCIATIONS.has(association) +} + +export function parseCommand(value) { + if (typeof value !== "string") throw new Error("The OpenCode command must be text.") + const body = value.replaceAll("\r\n", "\n") + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(body)) { + throw new Error("The OpenCode command contains unsupported control characters.") + } + const match = body.match( + /^\/(?:oc|opencode) +(plan|build)(?: +--model +(sol|terra|luna))? +(.+)$/s, + ) + + if (!match) { + throw new Error( + "Expected `/oc [--model ] ` with the command at the start of the comment.", + ) + } + + const mode = match[1] + const modelAlias = match[2] + const prompt = match[3].trim() + if (!prompt) throw new Error("The OpenCode request must not be empty.") + if (prompt.length > MAX_PROMPT_LENGTH) { + throw new Error(`The OpenCode request must not exceed ${MAX_PROMPT_LENGTH} characters.`) + } + if (GITHUB_ATTACHMENT_URL.test(prompt)) { + throw new Error("OpenCode commands cannot include GitHub attachment URLs.") + } + if (/^--model(?:\s|$)/i.test(prompt)) { + throw new Error("Model must be one of: sol, terra, luna.") + } + + return { + mode, + model: modelAlias ? MODELS[modelAlias] : DEFAULT_MODELS[mode], + prompt, + } +} + +function writeOutput(outputPath, name, value) { + const delimiter = `opencode_${randomUUID()}` + appendFileSync(outputPath, `${name}<<${delimiter}\n${value}\n${delimiter}\n`) +} + +export function main(env = process.env) { + if (!env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is required.") + if (!env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required.") + const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")) + const comment = event.comment + if (!comment) throw new Error("This workflow only accepts GitHub comment events.") + if (!isAllowedAssociation(comment.author_association)) { + throw new Error(`User association ${comment.author_association ?? "UNKNOWN"} is not authorized.`) + } + + const command = parseCommand(comment.body) + writeOutput(env.GITHUB_OUTPUT, "mode", command.mode) + writeOutput(env.GITHUB_OUTPUT, "model", command.model) + writeOutput(env.GITHUB_OUTPUT, "prompt", command.prompt) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/.github/scripts/opencode-command.test.mjs b/.github/scripts/opencode-command.test.mjs new file mode 100644 index 0000000..b47ec99 --- /dev/null +++ b/.github/scripts/opencode-command.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import test from "node:test" + +import { main, MAX_PROMPT_LENGTH, isAllowedAssociation, parseCommand } from "./opencode-command.mjs" + +test("uses separate defaults and preserves command aliases", () => { + assert.deepEqual(parseCommand("/oc plan inspect the role"), { + mode: "plan", + model: "openai/gpt-5.6-sol", + prompt: "inspect the role", + }) + assert.deepEqual(parseCommand("/opencode build implement the role"), { + mode: "build", + model: "openai/gpt-5.6-terra", + prompt: "implement the role", + }) +}) + +test("accepts only allowlisted model aliases", () => { + assert.equal(parseCommand("/oc build --model luna make the change").model, "openai/gpt-5.6-luna") + assert.throws(() => parseCommand("/oc build --model openai/gpt-5.6 make the change")) +}) + +test("requires an exact command at the beginning", () => { + assert.throws(() => parseCommand("please /oc plan inspect this")) + assert.throws(() => parseCommand("/oc build")) + assert.throws(() => parseCommand(" /oc plan inspect this")) +}) + +test("normalizes CRLF and preserves multiline prompts", () => { + assert.equal(parseCommand("/oc plan inspect this\r\nand include tests").prompt, "inspect this\nand include tests") +}) + +test("enforces the prompt length limit", () => { + assert.equal(parseCommand(`/oc plan ${"a".repeat(MAX_PROMPT_LENGTH)}`).prompt.length, MAX_PROMPT_LENGTH) + assert.throws(() => parseCommand(`/oc plan ${"a".repeat(MAX_PROMPT_LENGTH + 1)}`)) +}) + +test("rejects unsupported control characters but permits tab and newline", () => { + assert.equal(parseCommand("/oc plan line\n\tmore").prompt, "line\n\tmore") + assert.throws(() => parseCommand("/oc plan nul\0value")) + assert.throws(() => parseCommand("/oc plan escape\x1bvalue")) + assert.throws(() => parseCommand("/oc plan delete\x7fvalue")) +}) + +test("rejects GitHub attachment URLs before OpenCode can download them", () => { + assert.throws( + () => parseCommand("/oc plan inspect https://github.com/user-attachments/assets/unbounded"), + /attachment URLs/, + ) +}) + +test("allows only trusted repository associations", () => { + assert.equal(isAllowedAssociation("OWNER"), true) + assert.equal(isAllowedAssociation("MEMBER"), true) + assert.equal(isAllowedAssociation("COLLABORATOR"), true) + assert.equal(isAllowedAssociation("CONTRIBUTOR"), false) +}) + +test("main writes multiline outputs and validates its environment", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-command-")) + const eventPath = path.join(directory, "event.json") + const outputPath = path.join(directory, "output") + try { + await writeFile(eventPath, JSON.stringify({ comment: { author_association: "OWNER", body: "/oc plan first\nsecond" } })) + main({ GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath }) + const output = await readFile(outputPath, "utf8") + assert.match(output, /prompt< main({ GITHUB_OUTPUT: outputPath }), /GITHUB_EVENT_PATH/) + assert.throws(() => main({ GITHUB_EVENT_PATH: eventPath }), /GITHUB_OUTPUT/) + await writeFile(eventPath, "{") + assert.throws(() => main({ GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath }), SyntaxError) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/.github/scripts/opencode-permissions.mjs b/.github/scripts/opencode-permissions.mjs new file mode 100644 index 0000000..968ade0 --- /dev/null +++ b/.github/scripts/opencode-permissions.mjs @@ -0,0 +1,26 @@ +const BUILD_EDIT_PERMISSION = Object.freeze({ + "*": "allow", + ".git/**": "deny", + ".github/**": "deny", + ".opencode/**": "deny", + "opencode.json": "deny", + "AGENTS.md": "deny", + "vault_pass.sh": "deny", + ".env": "deny", + ".env.*": "deny", + "**/.env": "deny", + "**/.env.*": "deny", +}) + +export function isGithubActions(env = process.env) { + return env.GITHUB_ACTIONS === "true" +} + +export function enableGithubBuildEdits(config) { + const build = config?.agent?.["github-build"] + if (!build?.permission || build.permission.edit !== "deny") { + throw new Error("github-build must statically deny edits before guarded enablement") + } + + build.permission.edit = { ...BUILD_EDIT_PERMISSION } +} diff --git a/.github/scripts/opencode-permissions.test.mjs b/.github/scripts/opencode-permissions.test.mjs new file mode 100644 index 0000000..c571520 --- /dev/null +++ b/.github/scripts/opencode-permissions.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { enableGithubBuildEdits, isGithubActions } from "./opencode-permissions.mjs" + +test("GitHub-only guard activation requires the exact Actions environment value", () => { + assert.equal(isGithubActions({}), false) + assert.equal(isGithubActions({ GITHUB_ACTIONS: "false" }), false) + assert.equal(isGithubActions({ GITHUB_ACTIONS: "1" }), false) + assert.equal(isGithubActions({ GITHUB_ACTIONS: "TRUE" }), false) + assert.equal(isGithubActions({ GITHUB_ACTIONS: "true" }), true) +}) + +test("build edits are denied before guarded configuration", () => { + const config = { agent: { "github-build": { permission: { edit: "deny", read: "allow" } } } } + assert.equal(config.agent["github-build"].permission.edit, "deny") + enableGithubBuildEdits(config) + assert.deepEqual(config.agent["github-build"].permission.edit, { + "*": "allow", + ".git/**": "deny", + ".github/**": "deny", + ".opencode/**": "deny", + "opencode.json": "deny", + "AGENTS.md": "deny", + "vault_pass.sh": "deny", + ".env": "deny", + ".env.*": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + }) + assert.equal(config.agent["github-build"].permission.read, "allow") +}) + +test("configuration fails closed if static edit denial is absent", () => { + assert.throws(() => enableGithubBuildEdits({ agent: { "github-build": { permission: { edit: "allow" } } } })) + assert.throws(() => enableGithubBuildEdits({})) +}) diff --git a/.github/scripts/opencode-protected-paths.mjs b/.github/scripts/opencode-protected-paths.mjs new file mode 100644 index 0000000..8527c87 --- /dev/null +++ b/.github/scripts/opencode-protected-paths.mjs @@ -0,0 +1,80 @@ +import { lstat, readlink, realpath } from "node:fs/promises" +import path from "node:path" + +const PROTECTED_PATHS = [ + ".git", + ".github", + ".opencode", + "AGENTS.md", + "opencode.json", + "vault_pass.sh", +] + +export function isProtectedPath(value) { + if (typeof value !== "string" || !value.trim()) return true + + const candidate = value.trim().replaceAll("\\", "/") + if (candidate.startsWith("~")) return true + + const normalized = path.posix.normalize(candidate).replace(/^\.\//, "") + if (normalized === ".." || normalized.startsWith("../")) return true + if (normalized.split("/").some((segment) => segment === ".env" || segment.startsWith(".env."))) return true + + return PROTECTED_PATHS.some( + (protectedPath) => normalized === protectedPath || normalized.startsWith(`${protectedPath}/`), + ) +} + +export function protectedPatchPath(patchText) { + return patchPaths(patchText).find(isProtectedPath) +} + +export function patchPaths(patchText) { + if (typeof patchText !== "string") return ["invalid patch"] + + const prefixes = ["*** Add File:", "*** Delete File:", "*** Update File:", "*** Move to:"] + const paths = [] + for (const line of patchText.split("\n")) { + const prefix = prefixes.find((candidate) => line.startsWith(candidate)) + if (prefix) paths.push(line.slice(prefix.length).trim()) + } + return paths +} + +async function canonicalPath(value, depth = 0) { + if (depth > 40) throw new Error(`Too many symbolic links in ${value}`) + + try { + const stat = await lstat(value) + if (stat.isSymbolicLink()) { + return canonicalPath(path.resolve(path.dirname(value), await readlink(value)), depth + 1) + } + return realpath(value) + } catch (error) { + if (error?.code !== "ENOENT") throw error + const parent = path.dirname(value) + if (parent === value) return value + return path.resolve(await canonicalPath(parent, depth + 1), path.basename(value)) + } +} + +function isOutside(root, candidate) { + const relative = path.relative(root, candidate).replaceAll("\\", "/") + return relative === ".." || relative.startsWith("../") || path.isAbsolute(relative) +} + +export async function assertSafeWorkspacePath(worktree, value) { + if (typeof value !== "string" || !value.trim() || value.trim().startsWith("~")) { + throw new Error(`OpenCode GitHub policy blocks access to ${value}`) + } + + const root = await realpath(worktree) + const unresolved = path.resolve(root, value) + if (isOutside(root, unresolved)) throw new Error(`OpenCode GitHub policy blocks access to ${value}`) + + const candidate = await canonicalPath(unresolved) + if (isOutside(root, candidate)) throw new Error(`OpenCode GitHub policy blocks resolved path ${value}`) + + const relative = path.relative(root, candidate).replaceAll("\\", "/") + if (isProtectedPath(relative)) throw new Error(`OpenCode GitHub policy blocks access to ${value}`) +} diff --git a/.github/scripts/opencode-protected-paths.test.mjs b/.github/scripts/opencode-protected-paths.test.mjs new file mode 100644 index 0000000..a37219d --- /dev/null +++ b/.github/scripts/opencode-protected-paths.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import test from "node:test" + +import { + assertSafeWorkspacePath, + isProtectedPath, + protectedPatchPath, +} from "./opencode-protected-paths.mjs" + +test("recognizes protected files and normalized paths", () => { + assert.equal(isProtectedPath(".github/workflows/opencode.yml"), true) + assert.equal(isProtectedPath("roles/example/../../.opencode/plugin/guard.ts"), true) + assert.equal(isProtectedPath("./opencode.json"), true) + assert.equal(isProtectedPath("roles/example/.env.production"), true) + assert.equal(isProtectedPath("roles/example/tasks/main.yml"), false) +}) + +test("rejects protected apply_patch destinations", () => { + const patch = `*** Begin Patch +*** Update File: roles/example/tasks/main.yml +*** Move to: .git/config +@@ +-old ++new +*** End Patch` + assert.equal(protectedPatchPath(patch), ".git/config") + assert.equal( + protectedPatchPath("*** Begin Patch\n*** Add File: roles/example/README.md\n+safe\n*** End Patch"), + undefined, + ) +}) + +test("accepts safe relative and absolute workspace paths", async () => { + const worktree = await mkdtemp(path.join(os.tmpdir(), "opencode-guard-")) + try { + await mkdir(path.join(worktree, "roles", "example"), { recursive: true }) + await assert.doesNotReject(() => assertSafeWorkspacePath(worktree, "roles/example/tasks/main.yml")) + await assert.doesNotReject(() => assertSafeWorkspacePath(worktree, path.join(worktree, "roles/example/tasks/main.yml"))) + } finally { + await rm(worktree, { recursive: true, force: true }) + } +}) + +test("rejects direct, traversal, protected absolute, and external absolute paths", async () => { + const worktree = await mkdtemp(path.join(os.tmpdir(), "opencode-guard-")) + try { + await mkdir(path.join(worktree, ".github")) + await assert.rejects(() => assertSafeWorkspacePath(worktree, ".github/workflows/main.yml")) + await assert.rejects(() => assertSafeWorkspacePath(worktree, "../outside")) + await assert.rejects(() => assertSafeWorkspacePath(worktree, path.join(worktree, ".github/workflows/main.yml"))) + await assert.rejects(() => assertSafeWorkspacePath(worktree, path.dirname(worktree))) + } finally { + await rm(worktree, { recursive: true, force: true }) + } +}) + +test("rejects symlinks resolving to protected and external paths", async () => { + const worktree = await mkdtemp(path.join(os.tmpdir(), "opencode-guard-")) + try { + await mkdir(path.join(worktree, ".git")) + await mkdir(path.join(worktree, "roles", "example"), { recursive: true }) + await writeFile(path.join(worktree, ".git", "config"), "secret") + await symlink("../../.git/config", path.join(worktree, "roles", "example", "config")) + await symlink("../../.github/new.yml", path.join(worktree, "roles", "example", "broken")) + await symlink("/etc/passwd", path.join(worktree, "roles", "example", "external")) + await assert.rejects(() => assertSafeWorkspacePath(worktree, "roles/example/config")) + await assert.rejects(() => assertSafeWorkspacePath(worktree, "roles/example/broken")) + await assert.rejects(() => assertSafeWorkspacePath(worktree, "roles/example/external")) + } finally { + await rm(worktree, { recursive: true, force: true }) + } +}) diff --git a/.github/scripts/opencode-target.mjs b/.github/scripts/opencode-target.mjs new file mode 100644 index 0000000..cf7523d --- /dev/null +++ b/.github/scripts/opencode-target.mjs @@ -0,0 +1,175 @@ +import { appendFileSync, readFileSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { pathToFileURL } from "node:url" + +export const MAX_THREAD_COMMENTS = 10 +export const MAX_THREAD_CONTEXT_CHARACTERS = 60_000 +export const MAX_PULL_REQUEST_FILES = 100 +const CONTEXT_FORMAT_OVERHEAD = 10_000 + +export function classifyTarget(event) { + if (event.issue) { + return { type: event.issue.pull_request ? "pull_request" : "issue", number: event.issue.number } + } + if (event.pull_request) return { type: "pull_request", number: event.pull_request.number } + throw new Error("This workflow only accepts issue or pull request comment events.") +} + +export async function fetchPullRequest({ apiUrl, repository, number, token, fetchImpl = fetch }) { + if (!apiUrl || !repository || !token) throw new Error("GitHub API configuration is incomplete.") + const response = await fetchImpl(`${apiUrl}/repos/${repository}/pulls/${number}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + if (!response.ok) throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}.`) + return response.json() +} + +export async function fetchIssue({ apiUrl, repository, number, token, fetchImpl = fetch }) { + if (!apiUrl || !repository || !token) throw new Error("GitHub API configuration is incomplete.") + const response = await fetchImpl(`${apiUrl}/repos/${repository}/issues/${number}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + if (!response.ok) throw new Error(`GitHub issue lookup failed with HTTP ${response.status}.`) + return response.json() +} + +async function fetchTargetData({ apiUrl, repository, endpoint, token, fetchImpl = fetch }) { + const response = await fetchImpl(`${apiUrl}/repos/${repository}/${endpoint}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + if (!response.ok) throw new Error(`GitHub context lookup failed with HTTP ${response.status}.`) + return { value: await response.json(), hasNextPage: response.headers?.get?.("link")?.includes('rel="next"') === true } +} + +async function assertBoundedThread(target, options) { + const issueComments = await fetchTargetData({ + ...options, + endpoint: `issues/${target.number}/comments?per_page=${MAX_THREAD_COMMENTS + 1}`, + }) + const context = [ + options.subject.title, + options.subject.body, + options.subject.user?.login, + options.subject.created_at, + options.subject.state, + ...issueComments.value.flatMap((comment) => [comment.user?.login, comment.created_at, comment.body]), + ] + let itemCount = issueComments.value.length + if (target.type === "pull_request") { + const reviews = await fetchTargetData({ + ...options, + endpoint: `pulls/${target.number}/reviews?per_page=${MAX_THREAD_COMMENTS + 1}`, + }) + itemCount += reviews.value.length + context.push(...reviews.value.flatMap((review) => [review.user?.login, review.submitted_at, review.state, review.body])) + const reviewComments = await Promise.all( + reviews.value.map((review) => + fetchTargetData({ + ...options, + endpoint: `pulls/${target.number}/reviews/${review.id}/comments?per_page=${MAX_THREAD_COMMENTS + 1}`, + }), + ), + ) + itemCount += reviewComments.reduce((total, result) => total + result.value.length, 0) + context.push( + ...reviewComments.flatMap((result) => + result.value.flatMap((comment) => [comment.user?.login, comment.created_at, comment.path, comment.line, comment.body]), + ), + ) + const files = await fetchTargetData({ ...options, endpoint: `pulls/${target.number}/files?per_page=${MAX_PULL_REQUEST_FILES}` }) + if (files.value.length > MAX_PULL_REQUEST_FILES || files.hasNextPage) { + throw new Error(`OpenCode limits pull requests to ${MAX_PULL_REQUEST_FILES} changed files.`) + } + context.push(...files.value.flatMap((file) => [file.filename, file.status, file.additions, file.deletions])) + if (reviews.hasNextPage || reviewComments.some((result) => result.hasNextPage)) itemCount = MAX_THREAD_COMMENTS + 1 + } + if (itemCount > MAX_THREAD_COMMENTS || issueComments.hasNextPage) { + throw new Error(`OpenCode limits issue and pull request threads to ${MAX_THREAD_COMMENTS} comments.`) + } + + const text = context.filter((value) => typeof value === "string" || typeof value === "number") + const size = text.reduce((total, value) => total + String(value).length, CONTEXT_FORMAT_OVERHEAD) + if (size > MAX_THREAD_CONTEXT_CHARACTERS) { + throw new Error(`OpenCode limits issue and pull request context to ${MAX_THREAD_CONTEXT_CHARACTERS} characters.`) + } +} + +export async function inspectTarget(event, mode, options) { + const target = classifyTarget(event) + if (target.type === "issue") { + const issue = await fetchIssue({ ...options, number: target.number }) + await assertBoundedThread(target, { ...options, subject: issue }) + return { + target_type: "issue", + target_number: String(target.number), + target_state: issue.state, + target_updated_at: issue.updated_at, + base_ref: "", + head_ref: "", + head_sha: "", + head_repo: "", + same_repo: "true", + } + } + + const pull = await fetchPullRequest({ ...options, number: target.number }) + await assertBoundedThread(target, { ...options, subject: pull }) + const sameRepo = pull.head?.repo?.full_name === options.repository + const result = { + target_type: "pull_request", + target_number: String(target.number), + target_state: pull.state, + target_updated_at: pull.updated_at, + base_ref: pull.base?.ref, + head_ref: pull.head?.ref, + head_sha: pull.head?.sha, + head_repo: pull.head?.repo?.full_name, + same_repo: String(sameRepo), + } + if (Object.values(result).some((value) => typeof value !== "string")) { + throw new Error("GitHub returned incomplete pull request metadata.") + } + if (mode === "build" && !sameRepo) throw new Error("Build mode rejects fork pull requests.") + if (mode === "build" && pull.state !== "open") throw new Error("Build mode requires an open pull request.") + if (mode === "build" && pull.head.ref === options.defaultBranch) { + throw new Error("Build mode rejects pull requests whose head is the default branch.") + } + return result +} + +function writeOutputs(outputPath, outputs) { + for (const [name, value] of Object.entries(outputs)) { + const delimiter = `opencode_target_${randomUUID()}` + appendFileSync(outputPath, `${name}<<${delimiter}\n${value}\n${delimiter}\n`) + } +} + +export async function main(env = process.env) { + if (!env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is required.") + if (!env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required.") + if (!env.MODE || !["plan", "build"].includes(env.MODE)) throw new Error("MODE is invalid.") + const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")) + const outputs = await inspectTarget(event, env.MODE, { + apiUrl: env.GITHUB_API_URL, + repository: env.GITHUB_REPOSITORY, + defaultBranch: event.repository?.default_branch, + token: env.GITHUB_TOKEN, + }) + writeOutputs(env.GITHUB_OUTPUT, outputs) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/.github/scripts/opencode-target.test.mjs b/.github/scripts/opencode-target.test.mjs new file mode 100644 index 0000000..3a12ffe --- /dev/null +++ b/.github/scripts/opencode-target.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { MAX_PULL_REQUEST_FILES, MAX_THREAD_COMMENTS, classifyTarget, inspectTarget } from "./opencode-target.mjs" + +const repository = "owner/repo" +const pull = { + state: "open", + title: "Pull request", + body: "Body", + updated_at: "2026-07-22T00:00:00Z", + base: { ref: "main" }, + head: { ref: "feature", sha: "abc123", repo: { full_name: repository } }, +} +const issue = { + state: "open", + title: "Issue", + body: "Body", + updated_at: "2026-07-22T00:00:00Z", +} +const response = ({ pullValue = pull, issueValue = issue, issueComments = [], reviews = [], reviewComments = [], files = [] } = {}) => + async (url) => { + const value = url.includes("/issues/") && url.includes("/comments") + ? issueComments + : url.includes("/reviews/") && url.includes("/comments") + ? reviewComments + : url.includes("/reviews") + ? reviews + : url.includes("/files") + ? files + : url.includes("/pulls/") + ? pullValue + : issueValue + return { ok: true, status: 200, headers: { get: () => null }, json: async () => value } + } +const options = { apiUrl: "https://api.github.test", repository, defaultBranch: "main", token: "token" } + +test("classifies issues, PR conversations, and PR review comments", () => { + assert.deepEqual(classifyTarget({ issue: { number: 1 } }), { type: "issue", number: 1 }) + assert.deepEqual(classifyTarget({ issue: { number: 2, pull_request: {} } }), { type: "pull_request", number: 2 }) + assert.deepEqual(classifyTarget({ pull_request: { number: 3 } }), { type: "pull_request", number: 3 }) +}) + +test("issues do not call the API", async () => { + const result = await inspectTarget({ issue: { number: 4 } }, "build", { ...options, fetchImpl: response() }) + assert.equal(result.target_type, "issue") + assert.equal(result.target_number, "4") + assert.equal(result.target_updated_at, issue.updated_at) +}) + +test("captures current pull request metadata", async () => { + const result = await inspectTarget({ issue: { number: 5, pull_request: {} } }, "build", { ...options, fetchImpl: response() }) + assert.deepEqual(result, { + target_type: "pull_request", + target_number: "5", + target_state: "open", + target_updated_at: pull.updated_at, + base_ref: "main", + head_ref: "feature", + head_sha: "abc123", + head_repo: repository, + same_repo: "true", + }) +}) + +test("build rejects forks, closed PRs, and default-branch heads", async () => { + const event = { pull_request: { number: 6 } } + await assert.rejects( + () => inspectTarget(event, "build", { + ...options, + fetchImpl: response({ pullValue: { ...pull, head: { ...pull.head, repo: { full_name: "fork/repo" } } } }), + }), + /fork/, + ) + await assert.rejects( + () => inspectTarget(event, "build", { ...options, fetchImpl: response({ pullValue: { ...pull, state: "closed" } }) }), + /open/, + ) + await assert.rejects( + () => inspectTarget(event, "build", { + ...options, + fetchImpl: response({ pullValue: { ...pull, head: { ...pull.head, ref: "main" } } }), + }), + /default branch/, + ) +}) + +test("plan permits fork pull requests", async () => { + const result = await inspectTarget({ pull_request: { number: 7 } }, "plan", { + ...options, + fetchImpl: response({ pullValue: { ...pull, head: { ...pull.head, repo: { full_name: "fork/repo" } } } }), + }) + assert.equal(result.same_repo, "false") +}) + +test("rejects oversized thread context before model access", async () => { + const comments = Array.from({ length: MAX_THREAD_COMMENTS + 1 }, () => ({ body: "comment" })) + await assert.rejects( + () => inspectTarget({ issue: { number: 8 } }, "plan", { ...options, fetchImpl: response({ issueComments: comments }) }), + /limits issue and pull request threads/, + ) +}) + +test("counts PR reviews and nested review comments in the context limit", async () => { + const reviews = Array.from({ length: MAX_THREAD_COMMENTS + 1 }, (_, id) => ({ id, body: "review" })) + await assert.rejects( + () => inspectTarget({ pull_request: { number: 9 } }, "plan", { ...options, fetchImpl: response({ reviews }) }), + /limits issue and pull request threads/, + ) + await assert.rejects( + () => inspectTarget({ pull_request: { number: 9 } }, "plan", { + ...options, + fetchImpl: response({ reviews: [{ id: 1, body: "review" }], reviewComments: Array.from({ length: MAX_THREAD_COMMENTS }, () => ({ body: "comment" })) }), + }), + /limits issue and pull request threads/, + ) +}) + +test("bounds changed-file metadata included in OpenCode prompts", async () => { + const files = Array.from({ length: MAX_PULL_REQUEST_FILES + 1 }, (_, id) => ({ + filename: `roles/${id}`, + status: "modified", + additions: 1, + deletions: 1, + })) + await assert.rejects( + () => inspectTarget({ pull_request: { number: 10 } }, "plan", { ...options, fetchImpl: response({ files }) }), + /changed files/, + ) +}) + +test("counts review and file text toward the context limit", async () => { + await assert.rejects( + () => inspectTarget({ pull_request: { number: 11 } }, "plan", { + ...options, + fetchImpl: response({ reviews: [{ id: 1, body: "a".repeat(60_000) }] }), + }), + /context to/, + ) +}) diff --git a/.github/scripts/opencode-verify-target.mjs b/.github/scripts/opencode-verify-target.mjs new file mode 100644 index 0000000..c8932ba --- /dev/null +++ b/.github/scripts/opencode-verify-target.mjs @@ -0,0 +1,48 @@ +import { pathToFileURL } from "node:url" + +import { fetchIssue, fetchPullRequest } from "./opencode-target.mjs" + +export async function verifyTarget(env, fetchImpl = fetch) { + if (env.TARGET_TYPE === "issue") { + const issue = await fetchIssue({ + apiUrl: env.GITHUB_API_URL, + repository: env.GITHUB_REPOSITORY, + number: env.TARGET_NUMBER, + token: env.GITHUB_TOKEN, + fetchImpl, + }) + if (issue.state !== env.AUTHORIZED_TARGET_STATE) throw new Error("Issue state changed after authorization.") + if (issue.updated_at !== env.AUTHORIZED_UPDATED_AT) throw new Error("Issue context changed after authorization.") + return + } + if (env.TARGET_TYPE !== "pull_request") throw new Error("Authorized target type is invalid.") + + const pull = await fetchPullRequest({ + apiUrl: env.GITHUB_API_URL, + repository: env.GITHUB_REPOSITORY, + number: env.TARGET_NUMBER, + token: env.GITHUB_TOKEN, + fetchImpl, + }) + const checks = [ + [pull.state === "open", "Pull request is no longer open."], + [pull.updated_at === env.AUTHORIZED_UPDATED_AT, "Pull request context changed after authorization."], + [pull.head?.repo?.full_name === env.GITHUB_REPOSITORY, "Pull request is no longer from this repository."], + [pull.head?.ref !== env.DEFAULT_BRANCH, "Pull request head is the default branch."], + [pull.head?.sha === env.AUTHORIZED_HEAD_SHA, "Pull request head SHA changed after authorization."], + [pull.base?.ref === env.AUTHORIZED_BASE_REF, "Pull request base branch changed after authorization."], + [pull.head?.ref === env.AUTHORIZED_HEAD_REF, "Pull request head branch changed after authorization."], + [pull.head?.repo?.full_name === env.AUTHORIZED_HEAD_REPO, "Pull request head repository changed after authorization."], + ] + for (const [valid, message] of checks) { + if (!valid) throw new Error(message) + } +} + +export async function main(env = process.env) { + await verifyTarget(env) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/.github/scripts/opencode-verify-target.test.mjs b/.github/scripts/opencode-verify-target.test.mjs new file mode 100644 index 0000000..fb71959 --- /dev/null +++ b/.github/scripts/opencode-verify-target.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { verifyTarget } from "./opencode-verify-target.mjs" + +const env = { + TARGET_TYPE: "pull_request", + TARGET_NUMBER: "12", + GITHUB_API_URL: "https://api.github.test", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token", + AUTHORIZED_HEAD_SHA: "abc123", + AUTHORIZED_BASE_REF: "main", + AUTHORIZED_HEAD_REF: "feature", + AUTHORIZED_HEAD_REPO: "owner/repo", + AUTHORIZED_TARGET_STATE: "open", + AUTHORIZED_UPDATED_AT: "2026-07-22T00:00:00Z", + DEFAULT_BRANCH: "main", +} +const pull = { + state: "open", + updated_at: "2026-07-22T00:00:00Z", + base: { ref: "main" }, + head: { ref: "feature", sha: "abc123", repo: { full_name: "owner/repo" } }, +} +const fetchPull = (value) => async () => ({ ok: true, status: 200, json: async () => value }) + +test("issues require unchanged state and context", async () => { + const issueEnv = { + TARGET_TYPE: "issue", + TARGET_NUMBER: "12", + GITHUB_API_URL: "https://api.github.test", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_TOKEN: "token", + AUTHORIZED_TARGET_STATE: "open", + AUTHORIZED_UPDATED_AT: "2026-07-22T00:00:00Z", + } + const issue = { state: "open", updated_at: "2026-07-22T00:00:00Z" } + await assert.doesNotReject(() => verifyTarget(issueEnv, fetchPull(issue))) + await assert.rejects(() => verifyTarget(issueEnv, fetchPull({ ...issue, updated_at: "changed" })), /context changed/) +}) + +test("accepts unchanged authorized PR metadata", async () => { + await assert.doesNotReject(() => verifyTarget(env, fetchPull(pull))) +}) + +test("rejects changed or unsafe post-approval PR metadata", async () => { + const cases = [ + [{ ...pull, state: "closed" }, /no longer open/], + [{ ...pull, updated_at: "changed" }, /context changed/], + [{ ...pull, head: { ...pull.head, sha: "changed" } }, /SHA changed/], + [{ ...pull, base: { ref: "other" } }, /base branch changed/], + [{ ...pull, head: { ...pull.head, ref: "other" } }, /head branch changed/], + [{ ...pull, head: { ...pull.head, repo: { full_name: "fork/repo" } } }, /no longer from this repository/], + [{ ...pull, head: { ...pull.head, ref: "main" } }, /default branch/], + ] + for (const [value, message] of cases) { + await assert.rejects(() => verifyTarget(env, fetchPull(value)), message) + } +}) diff --git a/.github/workflows/ansible.yml b/.github/workflows/ansible.yml index 8fe8a5c..aee3bab 100644 --- a/.github/workflows/ansible.yml +++ b/.github/workflows/ansible.yml @@ -16,6 +16,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.13' @@ -44,36 +46,3 @@ jobs: env: ANSIBLE_ALLOW_BROKEN_CONDITIONALS: 'true' run: molecule test --scenario-name dawarich - - validate-vault: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: validate - runs-on: ubuntu-latest - environment: ansible-validation - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.13' - - name: Install Ansible tooling - run: python -m pip install --requirement requirements.txt - - name: Create temporary Vault password file - shell: bash - env: - ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$ANSIBLE_VAULT_PASSWORD" ]]; then - echo "ANSIBLE_VAULT_PASSWORD is not configured for the ansible-validation environment." >&2 - exit 1 - fi - umask 077 - printf '%s' "$ANSIBLE_VAULT_PASSWORD" > "$RUNNER_TEMP/ansible-vault-password" - - name: Validate Vault decryption - env: - ANSIBLE_VAULT_PASSWORD_FILE: ${{ runner.temp }}/ansible-vault-password - run: python scripts/validate_vault.py - - name: Remove temporary Vault password file - if: always() - shell: bash - run: rm -f "$RUNNER_TEMP/ansible-vault-password" diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..d651a01 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,186 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: OpenCode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +concurrency: + group: opencode-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + authorize: + if: >- + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') && + (startsWith(github.event.comment.body, '/oc ') || + startsWith(github.event.comment.body, '/opencode ')) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + issues: read + pull-requests: read + outputs: + mode: ${{ steps.command.outputs.mode }} + model: ${{ steps.command.outputs.model }} + prompt: ${{ steps.command.outputs.prompt }} + target_type: ${{ steps.target.outputs.target_type }} + target_number: ${{ steps.target.outputs.target_number }} + target_state: ${{ steps.target.outputs.target_state }} + target_updated_at: ${{ steps.target.outputs.target_updated_at }} + base_ref: ${{ steps.target.outputs.base_ref }} + head_ref: ${{ steps.target.outputs.head_ref }} + head_sha: ${{ steps.target.outputs.head_sha }} + head_repo: ${{ steps.target.outputs.head_repo }} + same_repo: ${{ steps.target.outputs.same_repo }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + - name: Parse and authorize command + id: command + run: node .github/scripts/opencode-command.mjs + - name: Require repository write access + env: + GITHUB_TOKEN: ${{ github.token }} + MODE: ${{ steps.command.outputs.mode }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + run: node .github/scripts/opencode-authorize-actor.mjs + - name: Authorize target + id: target + env: + GITHUB_TOKEN: ${{ github.token }} + MODE: ${{ steps.command.outputs.mode }} + run: node .github/scripts/opencode-target.mjs + + plan: + if: needs.authorize.outputs.mode == 'plan' + needs: authorize + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: opencode-plan + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + - name: Install pinned OpenCode + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/opencode-linux-x64.tar.gz" + install_dir="$RUNNER_TEMP/opencode" + mkdir -p "$install_dir" + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$archive" \ + https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-x64.tar.gz + printf '%s %s\n' \ + bab463c3fb3224d388bb7cfad63f38703df9cf0be2cfd2ce8cb49d886b53a174 \ + "$archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$install_dir" + "$install_dir/opencode" --version + printf '%s\n' "$install_dir" >> "$GITHUB_PATH" + - name: Configure OpenCode OAuth + env: + OPENAI_OAUTH_ACCESS_TOKEN: ${{ secrets.OPENAI_OAUTH_ACCESS_TOKEN }} + OPENAI_OAUTH_REFRESH_TOKEN: ${{ secrets.OPENAI_OAUTH_REFRESH_TOKEN }} + OPENAI_OAUTH_EXPIRES: ${{ secrets.OPENAI_OAUTH_EXPIRES }} + OPENAI_OAUTH_ACCOUNT_ID: ${{ secrets.OPENAI_OAUTH_ACCOUNT_ID }} + run: node .github/scripts/opencode-auth.mjs + - name: Run read-only OpenCode plan + env: + GITHUB_TOKEN: ${{ github.token }} + USE_GITHUB_TOKEN: 'true' + MODEL: ${{ needs.authorize.outputs.model }} + OPENCODE_CONFIG_CONTENT: >- + {"share":"disabled","default_agent":"github-plan","small_model":"openai/gpt-5.6-luna","enabled_providers":["openai"],"provider":{"openai":{"whitelist":["gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"]}},"formatter":false,"lsp":false} + PROMPT: ${{ needs.authorize.outputs.prompt }} + SHARE: 'false' + run: opencode github run + + build: + if: >- + needs.authorize.outputs.mode == 'build' && + vars.OPENCODE_BUILD_ENABLED == 'true' + needs: authorize + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: opencode-build + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + - name: Configure OpenCode commit identity + run: | + git config --local user.name 'github-actions[bot]' + git config --local user.email '41898282+github-actions[bot]@users.noreply.github.com' + - name: Install pinned OpenCode + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/opencode-linux-x64.tar.gz" + install_dir="$RUNNER_TEMP/opencode" + mkdir -p "$install_dir" + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$archive" \ + https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-x64.tar.gz + printf '%s %s\n' \ + bab463c3fb3224d388bb7cfad63f38703df9cf0be2cfd2ce8cb49d886b53a174 \ + "$archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$install_dir" + "$install_dir/opencode" --version + printf '%s\n' "$install_dir" >> "$GITHUB_PATH" + - name: Verify authorized target + env: + AUTHORIZED_BASE_REF: ${{ needs.authorize.outputs.base_ref }} + AUTHORIZED_HEAD_REF: ${{ needs.authorize.outputs.head_ref }} + AUTHORIZED_HEAD_REPO: ${{ needs.authorize.outputs.head_repo }} + AUTHORIZED_HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + AUTHORIZED_TARGET_STATE: ${{ needs.authorize.outputs.target_state }} + AUTHORIZED_UPDATED_AT: ${{ needs.authorize.outputs.target_updated_at }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_TOKEN: ${{ github.token }} + TARGET_NUMBER: ${{ needs.authorize.outputs.target_number }} + TARGET_TYPE: ${{ needs.authorize.outputs.target_type }} + run: node .github/scripts/opencode-verify-target.mjs + - name: Reauthorize commenter after approval + env: + GITHUB_TOKEN: ${{ github.token }} + MODE: build + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + run: node .github/scripts/opencode-authorize-actor.mjs + - name: Configure OpenCode OAuth + env: + OPENAI_OAUTH_ACCESS_TOKEN: ${{ secrets.OPENAI_OAUTH_ACCESS_TOKEN }} + OPENAI_OAUTH_REFRESH_TOKEN: ${{ secrets.OPENAI_OAUTH_REFRESH_TOKEN }} + OPENAI_OAUTH_EXPIRES: ${{ secrets.OPENAI_OAUTH_EXPIRES }} + OPENAI_OAUTH_ACCOUNT_ID: ${{ secrets.OPENAI_OAUTH_ACCOUNT_ID }} + run: node .github/scripts/opencode-auth.mjs + - name: Run constrained OpenCode build + env: + MODEL: ${{ needs.authorize.outputs.model }} + OPENCODE_CONFIG_CONTENT: >- + {"share":"disabled","default_agent":"github-build","small_model":"openai/gpt-5.6-luna","enabled_providers":["openai"],"provider":{"openai":{"whitelist":["gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"]}},"formatter":false,"lsp":false} + PROMPT: ${{ needs.authorize.outputs.prompt }} + SHARE: 'false' + run: opencode github run diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index db71e2b..8a9644a 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -3,23 +3,68 @@ name: GitHub workflow validation on: - pull_request: - paths: - - .github/workflows/** + pull_request: {} push: branches: - main - paths: - - .github/workflows/** permissions: contents: read jobs: actionlint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Test OpenCode scripts + run: node --test .github/scripts/*.test.mjs + - name: Install pinned OpenCode for policy smoke test + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/opencode-linux-x64.tar.gz" + install_dir="$RUNNER_TEMP/opencode" + mkdir -p "$install_dir" + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$archive" \ + https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-x64.tar.gz + printf '%s %s\n' \ + bab463c3fb3224d388bb7cfad63f38703df9cf0be2cfd2ce8cb49d886b53a174 \ + "$archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$install_dir" + printf '%s\n' "$install_dir" >> "$GITHUB_PATH" + - name: Smoke test GitHub OpenCode policy + shell: bash + run: | + set -euo pipefail + config="$RUNNER_TEMP/opencode-github-config.json" + GITHUB_ACTIONS=true \ + OPENCODE_CONFIG_CONTENT='{"share":"disabled","default_agent":"github-build","small_model":"openai/gpt-5.6-luna","enabled_providers":["openai"],"provider":{"openai":{"whitelist":["gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"]}},"formatter":false,"lsp":false}' \ + opencode debug config > "$config" + CONFIG="$config" node --input-type=module <<'NODE' + import assert from "node:assert/strict" + import { readFileSync } from "node:fs" + + const config = JSON.parse(readFileSync(process.env.CONFIG, "utf8")) + assert.equal(config.default_agent, "github-build") + assert.equal(config.share, "disabled") + assert.deepEqual(config.enabled_providers, ["openai"]) + assert.deepEqual(config.provider.openai.whitelist, ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) + assert.equal(config.agent["github-build"].permission.edit["*"], "allow") + assert.equal(config.agent["github-build"].permission.edit[".github/**"], "deny") + NODE + - name: Verify plan token separation + shell: bash + run: | + set -euo pipefail + plan_job="$(awk '/^ plan:/{inside=1} /^ build:/{inside=0} inside' .github/workflows/opencode.yml)" + if grep -q 'id-token: write' <<<"$plan_job"; then + exit 1 + fi + grep -q "USE_GITHUB_TOKEN: 'true'" .github/workflows/opencode.yml + grep -q 'Reauthorize commenter after approval' .github/workflows/opencode.yml - name: Validate GitHub Actions workflows uses: docker://rhysd/actionlint@sha256:ef8299f97635c4c30e2298f48f30763ab782a4ad2c95b744649439a039421e36 with: diff --git a/.opencode/agent/github-build.md b/.opencode/agent/github-build.md new file mode 100644 index 0000000..9c86e04 --- /dev/null +++ b/.opencode/agent/github-build.md @@ -0,0 +1,24 @@ +--- +description: Implements reviewer-authorized GitHub requests without shell or external access. +mode: primary +model: openai/gpt-5.6-terra +steps: 60 +permission: + "*": deny + read: + "*": allow + ".git/**": deny + "*.env": deny + "*.env.*": deny + "vault_pass.sh": deny + edit: deny + glob: allow + list: + "*": allow + ".git/**": deny + "*.env": deny + "*.env.*": deny + "vault_pass.sh": deny +--- + +Implement the requested repository changes directly. Stay within the requested scope. Do not execute commands, alter agent or workflow policy, access external resources, commit, push, merge, or claim validation that was not performed. GitHub Actions will commit the resulting worktree and run repository validation for human review. diff --git a/.opencode/agent/github-plan.md b/.opencode/agent/github-plan.md new file mode 100644 index 0000000..6e181e2 --- /dev/null +++ b/.opencode/agent/github-plan.md @@ -0,0 +1,23 @@ +--- +description: Produces read-only implementation plans for trusted GitHub comments. +mode: primary +model: openai/gpt-5.6-sol +steps: 30 +permission: + "*": deny + read: + "*": allow + ".git/**": deny + "*.env": deny + "*.env.*": deny + "vault_pass.sh": deny + glob: allow + list: + "*": allow + ".git/**": deny + "*.env": deny + "*.env.*": deny + "vault_pass.sh": deny +--- + +Analyze the request and repository, then return a concrete implementation plan. Do not modify files, execute commands, access external resources, or claim that changes were made. diff --git a/.opencode/plugin/github-guard.ts b/.opencode/plugin/github-guard.ts new file mode 100644 index 0000000..6b83b4d --- /dev/null +++ b/.opencode/plugin/github-guard.ts @@ -0,0 +1,27 @@ +import type { Plugin } from "@opencode-ai/plugin" + +export default (async ({ worktree }) => { + if (process.env.GITHUB_ACTIONS !== "true") return {} + + const [{ enableGithubBuildEdits }, { assertSafeWorkspacePath, patchPaths }] = await Promise.all([ + import("../../.github/scripts/opencode-permissions.mjs"), + import("../../.github/scripts/opencode-protected-paths.mjs"), + ]) + + return { + config: (config) => { + enableGithubBuildEdits(config) + }, + "tool.execute.before": async (input, output) => { + const paths: string[] = [] + if (input.tool === "apply_patch") paths.push(...patchPaths(output.args?.patchText)) + for (const key of ["filePath", "path"]) { + if (typeof output.args?.[key] === "string") paths.push(output.args[key]) + } + + for (const candidate of paths) { + await assertSafeWorkspacePath(worktree, candidate) + } + }, + } +}) satisfies Plugin diff --git a/AGENTS.md b/AGENTS.md index 7c55439..6200d4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ - Tags are the intended way to select roles and role components. `common.yml` requires `--ask-become-pass --ask-pass` for an initial host run. - `patch.yml` deliberately rolls Debian hosts out at 30%, 60%, then 100%, and Proxmox one host at a time; preserve that rollout behavior. - Firewall changes are high impact: the role validates `/etc/nftables.conf` with `nft --check` before applying it only when templates changed. -- GitHub Actions runs secret-free lint, syntax, and Molecule validation for pull requests. The `ansible-validation` environment must restrict deployments to `main` and supply `ANSIBLE_VAULT_PASSWORD` only to trusted pushes for decryption validation. It must not require approval because CI is non-interactive. +- GitHub Actions runs secret-free lint, syntax, and Molecule validation. Vault decryption remains a local operator responsibility and is not performed in CI. ## Roles And Containers diff --git a/docs/opencode-github.md b/docs/opencode-github.md new file mode 100644 index 0000000..1358259 --- /dev/null +++ b/docs/opencode-github.md @@ -0,0 +1,100 @@ +# OpenCode GitHub Agent + +OpenCode responds to explicit commands in GitHub issue comments, pull request conversations, and pull request review comments. GitHub calls merge requests "pull requests". + +## Security Model + +- Only the repository owner can invoke unapproved plan runs. Build requests require repository write/admin permission and the build-environment approval. +- Commands must start at the first character of a comment and use an allowlisted mode and model. Requests are limited to 8,000 characters; CRLF is normalized, and unsupported control characters are rejected. +- Plan runs use the scoped `GITHUB_TOKEN` with read-only contents permission plus issue and pull-request comment/reaction write access. Build runs alone exchange OIDC for the OpenCode App token. +- Build runs require approval through the `opencode-build` environment. Fork PRs, non-open PRs, and PRs whose head is the default branch are rejected. +- The build agent statically denies edits. A trusted startup plugin enables edits only for ordinary repository files and retains canonical and symlink path checks. If the plugin or config hook fails, editing stays denied. +- The build agent has no shell, network, subagent, external-directory, or merge access. It cannot edit `.github/`, `.opencode/`, `.git/`, `opencode.json`, `AGENTS.md`, `vault_pass.sh`, `.env`, or `.env.*` files. +- Comment workflows start from the trusted default branch before OpenCode processes PR content. +- Authorization caps all context sent by OpenCode at ten issue/review/review-thread comments, 100 changed files, and 60,000 characters before OAuth is exposed. GitHub attachment URLs are rejected rather than downloaded. It captures current target state, `updated_at`, PR base branch, head branch, head SHA, and head repository. After environment approval, build mode rechecks the commenter's permission and fetches the target again immediately before OpenCode, rejecting any relevant change. +- Builds may update any same-repository, non-default-branch PR head. A residual race remains: OpenCode v1.18.4 ultimately fetches and pushes by mutable branch name, so a branch can theoretically move after verification and before fetch or push. Execution is not immutable by SHA unless upstream changes this behavior; all such branches need equivalent protection where appropriate, and final human diff review remains mandatory. +- OpenCode sessions are never shared. The workflow downloads OpenCode v1.18.4 and verifies its pinned SHA-256 digest. +- OpenCode can create or update a branch and pull request but has no merge operation in its configured toolset. Protected branch rules provide the final merge boundary. +- A commenter must have effective repository `write` or `admin` permission in addition to the GitHub comment association gate. + +Role vars, host vars, group vars, encrypted Vault ciphertext, and repository topology remain readable by explicit accepted decision because they help the agent create consistent roles. The read-only SNMP community in `roles/prometheus/files/generator.yml` is also an accepted risk. OAuth must use the dedicated agentic-coding account. Secrets must remain encrypted or supplied through environments. + +## Local OpenCode + +The repository `opencode.json` only disables session sharing. Local OpenCode sessions otherwise retain the user's normal providers, models, built-in build agent, tools, formatter, and LSP behavior, allowing broader local changes when the operator chooses. + +The `github-plan` and `github-build` agents remain available locally but are not selected by default. The GitHub workflow injects the complete restrictive policy through `OPENCODE_CONFIG_CONTENT`. The auto-discovered guard plugin returns no hooks unless `GITHUB_ACTIONS` is exactly `true`, so it does not constrain local tools or paths. + +## Required Repository Settings + +Do not use build mode until all settings in this section are active. + +Create two GitHub environments: + +- `opencode-plan` has no required reviewer and contains only its copy of the four OAuth component secrets. +- `opencode-build` has a required reviewer, prevents self-review, disables administrator bypass, and contains a separate copy of the four OAuth component secrets. + +The installed OpenCode GitHub App must be installed only for this repository, have the minimum contents, issues, and pull-request permissions needed to create branches, comments, and pull requests, and have no branch/ruleset bypass. Build mode uses the App through OIDC so App-created changes trigger normal pull-request validation workflows; plan mode instead uses its scoped `GITHUB_TOKEN`. + +Create a ruleset or branch protection rule for `main` with: + +- Pull requests required before merging. +- At least one human approving review and code owner review required. +- Stale approvals dismissed when new commits are pushed. +- Approval required for the latest reviewable push. +- All conversations resolved before merging. +- `Ansible validation / validate` and `GitHub workflow validation / actionlint` required. +- Force pushes and branch deletion blocked. +- Rule bypass disabled, including for administrators. + +Do not enable the repository-wide "Allow GitHub Actions to create and approve pull requests" setting for OpenCode. The installed App creates pull requests instead. Required human approval, stale-review dismissal, latest-push approval, required checks, code ownership, and no bypass remain mandatory. Confirm neither workflows nor the App can satisfy required human review. + +Issues must be enabled and limited to collaborators. After every environment, secret, Actions, and branch/ruleset control is verified, create the Actions repository variable `OPENCODE_BUILD_ENABLED` with value `true` as the final activation step. Build remains skipped while this variable is absent or has any other value. + +## ChatGPT Authentication + +OpenCode uses ChatGPT Plus or Pro OAuth, not an OpenAI API key. Add these four environment secrets separately to both `opencode-plan` and `opencode-build`: + +- `OPENAI_OAUTH_ACCESS_TOKEN` +- `OPENAI_OAUTH_REFRESH_TOKEN` +- `OPENAI_OAUTH_EXPIRES` +- `OPENAI_OAUTH_ACCOUNT_ID` + +Obtain the values from the `openai` entry created by a local OpenCode `/connect` authentication. `OPENAI_OAUTH_EXPIRES` is the non-negative integer expiration value. Never add the complete auth JSON as a structured GitHub secret. The workflow requires every component, constructs the exact JSON only on the runner, masks it before writing `OPENCODE_AUTH_CONTENT` to `GITHUB_ENV`, and never exposes OAuth secrets to the authorization job. + +OAuth refreshes performed by ephemeral runners are not persisted to GitHub. If authentication expires or is rotated, authenticate locally again and replace all four values in both environments together. Rotate both environment copies immediately if any component may have been exposed. + +## Commands + +GitHub defaults use Sol for owner-only planning and Terra for approved contributor builds: + +```text +/oc plan describe how to add a new role +/oc build implement the approved role +``` + +Override the model with one of `sol`, `terra`, or `luna`: + +```text +/oc plan --model sol investigate this failure +/opencode build --model luna fix the documentation typo +``` + +The accepted syntax is: + +```text +/oc [--model ] +``` + +Model names map to `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, and `openai/gpt-5.6-luna`; arbitrary providers and model names are rejected in GitHub Actions. Plans are owner-only and may analyze fork PRs. Builds may operate only on issues and same-repository PRs that pass authorization and post-approval verification. + +Use `plan` first for non-trivial work. Review the result in the GitHub thread, then issue a separate `build` command. Inspect the complete generated PR diff and wait for required CI before human approval. + +## Updating OpenCode + +OpenCode is deliberately pinned by version and digest. To update it: + +1. Review the upstream release and GitHub command implementation, including whether it still fetches PR heads by branch name. +2. Update all three download URLs and SHA-256 values: the plan and build entries in `.github/workflows/opencode.yml`, plus the policy smoke-test entry in `.github/workflows/workflow-lint.yml`. +3. Run `node --test .github/scripts/*.test.mjs`, the GitHub-policy smoke test, and workflow validation, then test plan and build commands on a non-production issue. +4. Review workflow logs for accidental secret disclosure before regular use. diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..d8eb1c4 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled" +} diff --git a/readme.md b/readme.md index 8879c40..8851d02 100644 --- a/readme.md +++ b/readme.md @@ -29,10 +29,7 @@ As a network engineer I prefer to segregate my network a bit more than normally Next enter your variable to encrypt and press ctrl+d twice. ### Validating GitHub Actions workflows -GitHub Actions workflow files use the GitHub workflow schema in VS Code and are checked by a dedicated actionlint workflow. Install the repository hook once with `pre-commit install`, then run all local checks with `pre-commit run --all-files`. Actionlint can also run directly with `docker run --rm --volume "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.10`. - -### GitHub Actions Vault validation -Create a GitHub environment named `ansible-validation`, restrict its deployment branches to `main`, and add an environment secret named `ANSIBLE_VAULT_PASSWORD` containing only the raw Ansible Vault password. Do not add an approval rule if validation must remain non-interactive. Trusted pushes to `main` run `python scripts/validate_vault.py` after lint, syntax, and Molecule checks succeed. Pull requests never receive the Vault password. +GitHub Actions workflow files use the GitHub workflow schema in VS Code and are checked by a dedicated actionlint workflow. Install the repository hook once with `pre-commit install`, then run the hook with `pre-commit run --all-files`. OpenCode workflow scripts additionally require `node --test .github/scripts/*.test.mjs`. Actionlint can also run directly with `docker run --rm --volume "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.10`. ## Servicer Specific Info diff --git a/scripts/validate_vault.py b/scripts/validate_vault.py deleted file mode 100644 index 4334722..0000000 --- a/scripts/validate_vault.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -"""Validate every Ansible Vault payload without templating variable values.""" - -from __future__ import annotations - -import argparse -import os -import sys -from collections.abc import Mapping, Sequence -from pathlib import Path - -from ansible.parsing.dataloader import DataLoader -from ansible.parsing.vault import VaultLib, VaultSecret, is_encrypted - - -VAULT_HEADER = b"$ANSIBLE_VAULT;" -YAML_SUFFIXES = {".yaml", ".yml"} -EXCLUDED_PARTS = {".ansible", ".git", ".venv"} - - -def encrypted_payloads(value: object): - """Yield ciphertext from parsed Vault-tagged scalars without rendering Jinja.""" - ciphertext = getattr(value, "_ciphertext", None) - if ciphertext is not None and is_encrypted(ciphertext): - yield ciphertext - return - - if isinstance(value, Mapping): - for key, item in value.items(): - yield from encrypted_payloads(key) - yield from encrypted_payloads(item) - elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - for item in value: - yield from encrypted_payloads(item) - - -def candidate_files(root: Path): - for path in root.rglob("*"): - if not path.is_file() or EXCLUDED_PARTS.intersection(path.parts): - continue - - contents = path.read_bytes() - if contents.lstrip().startswith(VAULT_HEADER) or ( - path.suffix.lower() in YAML_SUFFIXES and VAULT_HEADER in contents - ): - yield path, contents - - -def validate_file(path: Path, contents: bytes, vault: VaultLib) -> int: - expected = contents.count(VAULT_HEADER) - if contents.lstrip().startswith(VAULT_HEADER): - vault.decrypt(contents.strip()) - return 1 - - parsed = DataLoader().load_from_file(str(path)) - payloads = list(encrypted_payloads(parsed)) - if len(payloads) != expected: - raise ValueError( - f"found {expected} Vault header(s), but parsed {len(payloads)} encrypted value(s)" - ) - - for payload in payloads: - vault.decrypt(payload) - return len(payloads) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path.cwd()) - parser.add_argument( - "--password-file", - type=Path, - default=os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"), - ) - args = parser.parse_args() - - if args.password_file is None: - parser.error("--password-file or ANSIBLE_VAULT_PASSWORD_FILE is required") - - password = args.password_file.read_bytes().rstrip(b"\r\n") - if not password: - parser.error("the Vault password file is empty") - - vault = VaultLib([("default", VaultSecret(password))]) - files = list(candidate_files(args.root.resolve())) - if not files: - print("No Ansible Vault payloads found.", file=sys.stderr) - return 1 - - decrypted = 0 - failures = 0 - for path, contents in files: - try: - decrypted += validate_file(path, contents, vault) - except Exception as error: # Ansible exposes several version-specific Vault errors. - failures += 1 - print(f"{path.relative_to(args.root.resolve())}: {error}", file=sys.stderr) - - if failures: - return 1 - - print(f"Validated {decrypted} Vault value(s) in {len(files)} file(s).") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())