Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb54f028e |
2 changed files with 65 additions and 74 deletions
96
index.ts
96
index.ts
|
|
@ -147,20 +147,13 @@ type PromptFiles = PromptFile[]
|
||||||
// ─── Auth config ─────────────────────────────────────────────────────────────
|
// ─── Auth config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function createAuthConfig(): Promise<string> {
|
async function createAuthConfig(): Promise<string> {
|
||||||
// Trim surrounding whitespace/newlines: a stray "\n" in the stored secret
|
const nomyoApiKey = process.env["NOMYO_API_KEY"]
|
||||||
// makes the "Authorization: Bearer <key>" header an invalid HTTP header value.
|
const nomyoApiUrl = process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api"
|
||||||
const nomyoApiKey = process.env["NOMYO_API_KEY"]?.trim()
|
|
||||||
const nomyoApiUrl = (process.env["NOMYO_API_URL"] || "https://chat.nomyo.ai/api").trim()
|
|
||||||
const modelEnv = process.env["MODEL"]
|
const modelEnv = process.env["MODEL"]
|
||||||
|
|
||||||
if (!nomyoApiKey) {
|
if (!nomyoApiKey) {
|
||||||
throw new Error('Environment variable "NOMYO_API_KEY" is not set')
|
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) {
|
if (!modelEnv) {
|
||||||
throw new Error('Environment variable "MODEL" is not set')
|
throw new Error('Environment variable "MODEL" is not set')
|
||||||
}
|
}
|
||||||
|
|
@ -203,10 +196,8 @@ async function createAuthConfig(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
await createAuthConfig()
|
await createAuthConfig()
|
||||||
|
|
||||||
// Strip credentials from opencode's env so its bash tool cannot reach them.
|
// Strip Forgejo write 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
|
const STRIP_FROM_AGENT_ENV = new Set(["FORGEJO_TOKEN", "FORGEJO_PUSH_TOKEN", "GITHUB_TOKEN"])
|
||||||
// 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"])
|
|
||||||
const agentEnv: NodeJS.ProcessEnv = {}
|
const agentEnv: NodeJS.ProcessEnv = {}
|
||||||
for (const [k, v] of Object.entries(process.env)) {
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v
|
if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v
|
||||||
|
|
@ -225,11 +216,9 @@ try {
|
||||||
forgejoHost = new URL(forgejoApiUrl).hostname
|
forgejoHost = new URL(forgejoApiUrl).hostname
|
||||||
accessToken = forgejoToken
|
accessToken = forgejoToken
|
||||||
|
|
||||||
// Gate on permissions before doing any work (fetching prompt images, etc.).
|
|
||||||
await assertPermissions()
|
|
||||||
|
|
||||||
const { userPrompt, promptFiles } = await getUserPrompt()
|
const { userPrompt, promptFiles } = await getUserPrompt()
|
||||||
await configureGitIdentity()
|
await configureGitIdentity()
|
||||||
|
await assertPermissions()
|
||||||
|
|
||||||
const comment = await createComment()
|
const comment = await createComment()
|
||||||
commentId = comment.id
|
commentId = comment.id
|
||||||
|
|
@ -666,48 +655,23 @@ async function assertPermissions() {
|
||||||
|
|
||||||
console.log(`Asserting permissions for user ${actor}...`)
|
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 {
|
try {
|
||||||
res = await fetch(url, { headers: { Authorization: `token ${forgejoToken}` } })
|
// Forgejo: check if user is a collaborator/member
|
||||||
|
await forgejoFetch<any>(
|
||||||
|
forgejoApiUrl("repos", context.repo.owner, context.repo.repo, "collaborators", actor),
|
||||||
|
)
|
||||||
|
console.log(" permission: write (collaborator)")
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Fail closed: if we cannot verify permissions, deny.
|
// If not a collaborator, check if actor is the repo owner (via GITHUB_ACTOR)
|
||||||
throw new Error(`Could not verify permissions for "${actor}": ${error.message}`)
|
if (actor === context.repo.owner || actor === `${context.repo.owner}[bot]`) {
|
||||||
}
|
console.log(" permission: admin (owner)")
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const { permission } = (await res.json().catch(() => ({}))) as { permission?: string }
|
|
||||||
if (permission === "owner" || permission === "admin" || permission === "write") {
|
|
||||||
console.log(` permission: ${permission}`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error(
|
console.error(`Failed to check permissions: ${error.message}`)
|
||||||
`User "${actor}" is not authorized to trigger this action ` +
|
// In Actions context, if we can write to the repo, we have write access
|
||||||
`(requires write access to ${context.repo.owner}/${context.repo.repo}, has "${permission ?? "none"}").`,
|
// 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 ─────────────────────────────────────────────────────────
|
// ─── Git operations ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -962,35 +926,19 @@ async function chat(text: string, files: PromptFiles = []) {
|
||||||
if (!chatRes.ok) {
|
if (!chatRes.ok) {
|
||||||
throw new Error(`opencode /chat returned ${chatRes.status} ${chatRes.statusText}: ${rawText.slice(0, 1000)}`)
|
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 {
|
try {
|
||||||
chatData = JSON.parse(rawText)
|
chatData = JSON.parse(rawText)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`opencode /chat returned non-JSON (status ${chatRes.status}, content-type ${chatRes.headers.get("content-type")}): ${rawText.slice(0, 1000)}`)
|
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 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.
|
return match.text
|
||||||
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)})`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Prompt building ────────────────────────────────────────────────────────
|
// ─── Prompt building ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,13 @@ runs:
|
||||||
run: |
|
run: |
|
||||||
.nyx-src/target/release/nyx scan --format json --quiet > nyx-results-raw.json 2>nyx-scan.stderr
|
.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 "
|
python3 -c "
|
||||||
import json, os
|
import json, os
|
||||||
|
|
||||||
|
|
@ -61,8 +68,10 @@ runs:
|
||||||
with open('.nyx/triage.json') as f:
|
with open('.nyx/triage.json') as f:
|
||||||
triage = json.load(f)
|
triage = json.load(f)
|
||||||
rules = triage.get('suppression_rules', [])
|
rules = triage.get('suppression_rules', [])
|
||||||
|
decisions = triage.get('decisions', [])
|
||||||
except:
|
except:
|
||||||
rules = []
|
rules = []
|
||||||
|
decisions = []
|
||||||
|
|
||||||
def rel_path(p):
|
def rel_path(p):
|
||||||
p = p.replace(workspace, '').lstrip('/')
|
p = p.replace(workspace, '').lstrip('/')
|
||||||
|
|
@ -76,7 +85,41 @@ runs:
|
||||||
i = rid.find(' (source ')
|
i = rid.find(' (source ')
|
||||||
return rid[:i] if i != -1 else rid
|
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):
|
def is_suppressed(f):
|
||||||
|
if decided(f):
|
||||||
|
return True
|
||||||
rule_id = base_rule(f.get('id', ''))
|
rule_id = base_rule(f.get('id', ''))
|
||||||
path = rel_path(f.get('path', ''))
|
path = rel_path(f.get('path', ''))
|
||||||
for r in rules:
|
for r in rules:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue