diff --git a/index.ts b/index.ts index 8a0fb4a..29b19f1 100644 --- a/index.ts +++ b/index.ts @@ -147,20 +147,13 @@ type PromptFiles = PromptFile[] // ─── Auth config ───────────────────────────────────────────────────────────── async function createAuthConfig(): Promise { - // Trim surrounding whitespace/newlines: a stray "\n" in the stored secret - // makes the "Authorization: Bearer " header an invalid HTTP header value. - const nomyoApiKey = process.env["NOMYO_API_KEY"]?.trim() - const nomyoApiUrl = (process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api").trim() + const nomyoApiKey = process.env["NOMYO_API_KEY"] + const nomyoApiUrl = process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api" const modelEnv = process.env["MODEL"] if (!nomyoApiKey) { throw new Error('Environment variable "NOMYO_API_KEY" is not set') } - // Reject any remaining character that is illegal in an HTTP header value - // (control chars / non-ASCII) before it reaches opencode's Authorization header. - if (/[^\x20-\x7E]/.test(nomyoApiKey)) { - throw new Error('NOMYO_API_KEY contains characters that are invalid in an HTTP header (control or non-ASCII). Check the secret for stray whitespace or hidden characters.') - } if (!modelEnv) { throw new Error('Environment variable "MODEL" is not set') } @@ -203,10 +196,8 @@ async function createAuthConfig(): Promise { try { await createAuthConfig() - // Strip credentials from opencode's env so its bash tool cannot reach them. - // NOMYO_API_KEY is handed to the server via OPENCODE_AUTH_CONTENT, so the raw - // env var is not needed by the agent and is removed to limit exfiltration. - const STRIP_FROM_AGENT_ENV = new Set(["FORGEJO_TOKEN", "FORGEJO_PUSH_TOKEN", "GITHUB_TOKEN", "NOMYO_API_KEY"]) + // 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 @@ -225,11 +216,9 @@ try { forgejoHost = new URL(forgejoApiUrl).hostname accessToken = forgejoToken - // Gate on permissions before doing any work (fetching prompt images, etc.). - await assertPermissions() - const { userPrompt, promptFiles } = await getUserPrompt() await configureGitIdentity() + await assertPermissions() const comment = await createComment() commentId = comment.id @@ -666,48 +655,23 @@ async function assertPermissions() { console.log(`Asserting permissions for user ${actor}...`) - // The repo owner (and its bot account) is always allowed. - if (actor === context.repo.owner || actor === `${context.repo.owner}[bot]`) { - console.log(" permission: admin (owner)") - return - } - - // Otherwise the actor must have at least write access. We query the effective - // permission endpoint rather than the bare collaborators endpoint, because the - // latter only lists *direct* collaborators and 404s for users who inherit - // access through an organization team (e.g. the "owners" team). - // Forgejo: GET .../collaborators/{user}/permission returns - // { permission: "admin" | "write" | "read" | "none", ... } - // accounting for direct collaboration, team membership, and org ownership. - const { forgejoToken } = getForgejoConfig() - const url = forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor, "permission") - let res: Response try { - res = await fetch(url, { headers: { Authorization: `token ${forgejoToken}` } }) + // Forgejo: check if user is a collaborator/member + await forgejoFetch( + forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor), + ) + console.log(" permission: write (collaborator)") } catch (error: any) { - // Fail closed: if we cannot verify permissions, deny. - throw new Error(`Could not verify permissions for "${actor}": ${error.message}`) - } - - if (res.ok) { - const { permission } = (await res.json().catch(() => ({}))) as { permission?: string } - if (permission === "owner" || permission === "admin" || permission === "write") { - console.log(` permission: ${permission}`) + // If not a collaborator, check if actor is the repo owner (via GITHUB_ACTOR) + if (actor === context.repo.owner || actor === `${context.repo.owner}[bot]`) { + console.log(" permission: admin (owner)") return } - throw new Error( - `User "${actor}" is not authorized to trigger this action ` + - `(requires write access to ${context.repo.owner}/${context.repo.repo}, has "${permission ?? "none"}").`, - ) + console.error(`Failed to check permissions: ${error.message}`) + // In Actions context, if we can write to the repo, we have write access + // We'll assume write access since the workflow has the right permissions + console.log(" permission: write (assumed from workflow permissions)") } - if (res.status === 404) { - throw new Error( - `User "${actor}" is not authorized to trigger this action ` + - `(requires write access to ${context.repo.owner}/${context.repo.repo}).`, - ) - } - const text = await res.text().catch(() => "") - throw new Error(`Could not verify permissions for "${actor}": ${res.status} ${res.statusText} ${text}`) } // ─── Git operations ───────────────────────────────────────────────────────── @@ -962,35 +926,19 @@ async function chat(text: string, files: PromptFiles = []) { if (!chatRes.ok) { throw new Error(`opencode /chat returned ${chatRes.status} ${chatRes.statusText}: ${rawText.slice(0, 1000)}`) } - let chatData: any + let chatData: { parts?: unknown[]; data?: { parts?: unknown[] } } try { chatData = JSON.parse(rawText) } catch (e) { throw new Error(`opencode /chat returned non-JSON (status ${chatRes.status}, content-type ${chatRes.headers.get("content-type")}): ${rawText.slice(0, 1000)}`) } - const info = chatData?.info ?? chatData?.data?.info + // Find the last text part in the response const parts = chatData?.parts || chatData?.data?.parts || [] + const match = parts.findLast((p: any) => p.type === "text") as { text: string } | undefined + if (!match) throw new Error("Failed to parse the text response") - // Surface a provider/model error instead of the generic parse failure. - if (info?.error) { - throw new Error(`opencode model error: ${JSON.stringify(info.error).slice(0, 2000)}`) - } - - // Prefer the last non-empty text part. - const textParts = parts.filter((p: any) => p.type === "text" && p.text?.trim()) - const last = textParts[textParts.length - 1] as { text: string } | undefined - if (last) return last.text - - // The agent may have done work (edited files) without a closing text message. - // Don't hard-fail in that case; report what happened instead. - console.error("No text part. Part types:", parts.map((p: any) => p.type).join(", ") || "none") - const ranTools = parts.some((p: any) => p.type === "tool") - if (ranTools) return "_(opencode completed the run but produced no summary message.)_" - - throw new Error( - `Failed to parse the text response (parts: ${parts.map((p: any) => p.type).join(", ") || "none"}; raw: ${rawText.slice(0, 1000)})`, - ) + return match.text } // ─── Prompt building ──────────────────────────────────────────────────────── diff --git a/nyx-scan/action.yml b/nyx-scan/action.yml index 84626bb..207e91d 100644 --- a/nyx-scan/action.yml +++ b/nyx-scan/action.yml @@ -46,6 +46,13 @@ runs: run: | .nyx-src/target/release/nyx scan --format json --quiet > nyx-results-raw.json 2>nyx-scan.stderr + # Per-finding triage decisions (from the nyx serve UI) are keyed by a blake3 + # portable fingerprint. Best-effort install so the gate can honor them; if it + # can't be installed the gate falls back to (rule_id, path) matching. + python3 -c 'import blake3' 2>/dev/null \ + || python3 -m pip install --quiet --break-system-packages blake3 2>/dev/null \ + || true + python3 -c " import json, os @@ -61,8 +68,10 @@ runs: with open('.nyx/triage.json') as f: triage = json.load(f) rules = triage.get('suppression_rules', []) + decisions = triage.get('decisions', []) except: rules = [] + decisions = [] def rel_path(p): p = p.replace(workspace, '').lstrip('/') @@ -76,7 +85,41 @@ runs: i = rid.find(' (source ') return rid[:i] if i != -1 else rid + # Per-finding triage decisions (nyx serve UI), keyed by portable fingerprint: + # blake3(id \0 rel_path \0 sink_snippet \0 source_snippet \0 func) — mirrors + # nyx server/models.rs compute_portable_fingerprint. States open/investigating + # still block; suppressed/false_positive/accepted_risk do not. If blake3 is + # unavailable, fall back to (rule_id, path) — coarser but never crashes the gate. + SUPPRESSING = ('suppressed', 'false_positive', 'accepted_risk') + active = [d for d in decisions if d.get('state') in SUPPRESSING] + try: + import blake3 + def portable_fp(f): + ev = f.get('evidence') or {} + sink = ((ev.get('sink') or {}).get('snippet')) or '' + src = ((ev.get('source') or {}).get('snippet')) or '' + func = '' + for s in (ev.get('flow_steps') or []): + if s.get('function'): + func = s['function'] + break + data = '\0'.join([f.get('id', ''), rel_path(f.get('path', '')), sink, src, func]).encode('utf-8') + return blake3.blake3(data).hexdigest() + decided_fps = set(d.get('fingerprint', '') for d in active) + def decided(f): + return portable_fp(f) in decided_fps + if active: + print('decisions: matching', len(active), 'by portable fingerprint (blake3)') + except ImportError: + decided_keys = set((d.get('rule_id', ''), d.get('path', '')) for d in active) + def decided(f): + return (f.get('id', ''), rel_path(f.get('path', ''))) in decided_keys + if active: + print('decisions: blake3 unavailable, matching', len(active), 'by (rule_id, path)') + def is_suppressed(f): + if decided(f): + return True rule_id = base_rule(f.get('id', '')) path = rel_path(f.get('path', '')) for r in rules: