Dogfood: run Gatekeep on this repo's PRs
Gatekeep evals / eval-floor (push) Successful in 8s

Add .github/workflows/gatekeep-pr.yml with uses: ./ and
OPENROUTER_API_KEY from secrets. Forge-agnostic API helper for Gitea.
This commit is contained in:
layue13 committed 2026-10-26 20:40:51 +00:00
commit 05f8fad38a
48 files changed
+2844

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Call one model via provider protocol.
# Protocols: openai | openai-completions | openai-responses | anthropic
# (openai / openai-compatible → chat completions)
# Args: <provider> <model> <protocol> <base_url> <prompt.json> <out.json> <secrets.json>
set -euo pipefail
PROVIDER="${1:?provider}"
MODEL="${2:?model}"
PROTOCOL="${3:?protocol}"
BASE_URL="${4:?base_url}"
PROMPT_FILE="${5:?prompt}"
OUT_FILE="${6:?out}"
SECRETS_FILE="${7:?secrets}"
KEY=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],""))' "$SECRETS_FILE" "$PROVIDER")
if [[ -z "$KEY" ]]; then
echo "missing key for $PROVIDER" >&2
echo "{\"error\":\"missing_key\",\"model\":\"$MODEL\",\"provider\":\"$PROVIDER\"}" > "$OUT_FILE"
exit 0
fi
MAX_TOKENS="${GATEKEEP_MAX_TOKENS:-4096}"
SYSTEM=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["system"])' "$PROMPT_FILE")
USER=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["user"])' "$PROMPT_FILE")
LABEL="${PROVIDER}:${MODEL}"
# Normalize protocol aliases
WIRE=$(python3 -c 'import sys
p=sys.argv[1].lower()
m={"openai":"openai-completions","openai-compatible":"openai-completions",
"openai-completions":"openai-completions","openai-responses":"openai-responses",
"anthropic":"anthropic"}
print(m.get(p,""))' "$PROTOCOL")
if [[ -z "$WIRE" ]]; then
echo "{\"error\":\"bad_protocol\",\"protocol\":\"$PROTOCOL\",\"model\":\"$MODEL\"}" > "$OUT_FILE"
exit 0
fi
if [[ "$WIRE" == "openai-completions" ]]; then
BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY'
import json,sys
model, system, user, max_tokens = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
print(json.dumps({
"model": model,
"temperature": 0,
"max_tokens": max_tokens,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
}))
PY
)
HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \
"${BASE_URL%/}/chat/completions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "HTTP-Referer: https://github.com/${GITHUB_REPOSITORY:-gatekeep}" \
-H "X-Title: Gatekeep" \
-d "$BODY")
EXTRACT=openai
elif [[ "$WIRE" == "openai-responses" ]]; then
BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY'
import json,sys
model, system, user, max_tokens = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
print(json.dumps({
"model": model,
"temperature": 0,
"max_output_tokens": max_tokens,
"input": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
}))
PY
)
HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \
"${BASE_URL%/}/responses" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d "$BODY")
EXTRACT=openai-responses
elif [[ "$WIRE" == "anthropic" ]]; then
BODY=$(python3 - "$MODEL" "$SYSTEM" "$USER" "$MAX_TOKENS" <<'PY'
import json,sys
model, system, user, max_tokens = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
print(json.dumps({
"model": model,
"max_tokens": max_tokens,
"temperature": 0,
"system": system,
"messages": [{"role": "user", "content": user}],
}))
PY
)
HTTP_CODE=$(curl -sS -o "$OUT_FILE.raw" -w "%{http_code}" \
"${BASE_URL%/}/v1/messages" \
-H "x-api-key: $KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d "$BODY")
EXTRACT=anthropic
fi
if [[ "$HTTP_CODE" != "200" ]]; then
echo "$LABEL failed HTTP $HTTP_CODE" >&2
head -c 800 "$OUT_FILE.raw" >&2 || true
echo "{\"error\":\"http\",\"model\":\"$MODEL\",\"provider\":\"$PROVIDER\",\"http\":$HTTP_CODE}" > "$OUT_FILE"
exit 0
fi
python3 - "$OUT_FILE.raw" "$OUT_FILE" "$MODEL" "$PROVIDER" "$EXTRACT" <<'PY'
import json,sys
raw=json.load(open(sys.argv[1]))
out, model, provider, kind = sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
if kind == "anthropic":
text = "".join(
(c.get("text") or "")
for c in (raw.get("content") or [])
if c.get("type") == "text"
)
elif kind == "openai-responses":
# Best-effort extract from Responses API
text = raw.get("output_text") or ""
if not text:
chunks = []
for item in raw.get("output") or []:
for c in item.get("content") or []:
if c.get("type") in ("output_text", "text"):
chunks.append(c.get("text") or "")
text = "".join(chunks)
else:
text = (((raw.get("choices") or [{}])[0].get("message") or {}).get("content")) or ""
start, end = text.find("{"), text.rfind("}")
payload = {}
if start >= 0 and end > start:
try:
payload = json.loads(text[start:end+1])
except Exception as e:
payload = {"parse_error": str(e), "raw_text": text[:2000]}
else:
payload = {"parse_error": "no_json", "raw_text": text[:2000]}
payload["_model"] = f"{provider}:{model}"
payload["_provider"] = provider
json.dump(payload, open(out,"w"), indent=2)
PY
echo "$LABEL → $OUT_FILE" >&2
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Discover build/test/benchmark entry points from common manifests.
# No Minecraft-hardcoded commands — agent/caller repos vary.
set -euo pipefail
ROOT="${GATEKEEP_ROOT:-.}"
OUT_DIR="$ROOT/.gatekeep/out"
mkdir -p "$OUT_DIR"
json_escape() {
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"$1"
}
entries=()
add() {
local kind="$1" cmd="$2" src="$3"
entries+=("{\"kind\":$(json_escape "$kind"),\"command\":$(json_escape "$cmd"),\"source\":$(json_escape "$src")}")
}
if [[ -f "$ROOT/package.json" ]]; then
# Prefer known script names without requiring jq
for s in test check lint build verify bench benchmark start; do
if python3 - "$ROOT/package.json" "$s" <<'PY'
import json,sys
p=json.load(open(sys.argv[1]))
scripts=p.get("scripts") or {}
sys.exit(0 if sys.argv[2] in scripts else 1)
PY
then
add "npm:$s" "npm run $s" "package.json"
fi
done
fi
if [[ -f "$ROOT/Makefile" ]]; then
for t in test check verify bench benchmark build; do
if grep -qE "^${t}[[:space:]]*:" "$ROOT/Makefile"; then
add "make:$t" "make $t" "Makefile"
fi
done
fi
if [[ -f "$ROOT/gradlew" ]]; then
add "gradle:test" "./gradlew test" "gradlew"
add "gradle:build" "./gradlew build" "gradlew"
fi
if [[ -f "$ROOT/pom.xml" ]]; then
add "mvn:test" "mvn -B test" "pom.xml"
fi
if [[ -f "$ROOT/Cargo.toml" ]]; then
add "cargo:test" "cargo test" "Cargo.toml"
add "cargo:bench" "cargo bench" "Cargo.toml"
fi
if [[ -f "$ROOT/go.mod" ]]; then
add "go:test" "go test ./..." "go.mod"
fi
if [[ -f "$ROOT/pyproject.toml" ]] || [[ -f "$ROOT/pytest.ini" ]]; then
add "pytest" "pytest -q" "python"
fi
# README hints (non-executing notes for the model/verify layer)
readme_hints=""
for f in README.md README.rst docs/README.md; do
if [[ -f "$ROOT/$f" ]]; then
readme_hints+=$(grep -iE '^(#+ ).*(test|build|bench|verify|ci)' "$ROOT/$f" | head -20 || true)
readme_hints+=$'\n'
fi
done
joined=$(IFS=,; echo "${entries[*]}")
discover_json="{\"entries\":[${joined}],\"readme_hints\":$(json_escape "$readme_hints")}"
printf '%s\n' "$discover_json" > "$OUT_DIR/discover.json"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "discover_json<<GATEKEEP_EOF"
echo "$discover_json"
echo "GATEKEEP_EOF"
} >> "$GITHUB_OUTPUT"
fi
echo "Discovered ${#entries[@]} entry point(s)" >&2
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Offline eval of deterministic policy floor (fail-closed). No OpenRouter calls.
set -euo pipefail
CASES_DIR="${1:?evals/cases dir}"
POLICY="${2:-}"
ACTION_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export GATEKEEP_ACTION="$ACTION_ROOT"
export GATEKEEP_ROOT="${GATEKEEP_ROOT:-$ACTION_ROOT}"
export CONFIG_DIR="${CONFIG_DIR:-}"
# shellcheck source=lib-config.sh
source "$ACTION_ROOT/scripts/lib-config.sh"
POLICY="${POLICY:-$(gatekeep_config_file policy.json)}"
failed=0
total=0
for case in "$CASES_DIR"/*.json; do
[[ -f "$case" ]] || continue
[[ "$(basename "$case")" == _template.json ]] && continue
total=$((total + 1))
python3 - "$case" "$POLICY" <<'PY' || failed=$((failed + 1))
import json, fnmatch, sys
case = json.load(open(sys.argv[1]))
policy = json.load(open(sys.argv[2]))
fx = case["fixture"]
files = fx.get("files") or []
labels = [x.lower() for x in (fx.get("labels") or [])]
add = int(fx.get("additions") or 10)
delete = int(fx.get("deletions") or 2)
ci = fx.get("ci_status") or "success"
rank = {"low": 0, "medium": 1, "high": 2}
floor = "low"
reasons = []
def bump(t, why):
global floor
if rank[t] > rank[floor]:
floor = t
reasons.append(why)
def match(path, glob):
g = glob.replace("**/", "*").replace("**", "*")
return fnmatch.fnmatch(path, glob) or fnmatch.fnmatch(path, g)
for lab in labels:
if lab in [x.lower() for x in policy.get("force_high_labels", [])]:
bump("high", f"label {lab}")
if lab in [x.lower() for x in policy.get("force_medium_labels", [])]:
bump("medium", f"label {lab}")
for f in files:
for g in policy.get("high_path_globs", []):
if match(f, g):
bump("high", f"{f}~{g}")
for g in policy.get("medium_path_globs", []):
if match(f, g):
bump("medium", f"{f}~{g}")
if len(files) > int(policy.get("low_max_files", 8)):
bump("medium", "file count")
if add + delete > int(policy.get("low_max_diff_lines", 200)):
bump("medium", "diff lines")
if ci in ("failure", "error"):
bump("high", f"ci {ci}")
min_t = case["expect_min_tier"]
exact = case.get("expect_tier")
ok = rank[floor] >= rank[min_t] and (exact is None or floor == exact)
status = "PASS" if ok else "FAIL"
print(f"{status} {case['id']}: floor={floor} min={min_t}" + (f" expect={exact}" if exact else ""))
if not ok:
print(" " + "; ".join(reasons) or "(none)")
print(" " + case.get("description", ""))
sys.exit(1)
PY
done
echo ""
echo "$((total - failed))/$total passed"
exit "$failed"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Minimal GitHub / Gitea API helper for Gatekeep (no gh CLI required).
Uses GITHUB_API_URL + GITHUB_TOKEN (Gitea Actions sets these to the forge).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
def api_base() -> str:
base = (os.environ.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
return base
def is_gitea() -> bool:
b = api_base().lower()
return "api.github.com" not in b
def token() -> str:
return (
os.environ.get("GITHUB_TOKEN")
or os.environ.get("GITEA_TOKEN")
or os.environ.get("GH_TOKEN")
or ""
)
def request(method: str, path: str, body: dict | None = None, accept: str | None = None) -> tuple[int, str]:
path = path if path.startswith("/") else f"/{path}"
url = f"{api_base()}{path}"
data = None if body is None else json.dumps(body).encode("utf-8")
headers = {
"Authorization": f"token {token()}" if is_gitea() else f"Bearer {token()}",
"Accept": accept or ("application/json" if is_gitea() else "application/vnd.github+json"),
"User-Agent": "gatekeep-action",
}
if body is not None:
headers["Content-Type"] = "application/json"
# GitHub also accepts token prefix
if not is_gitea() and token():
headers["Authorization"] = f"Bearer {token()}"
req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.status, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="replace")
def request_json(method: str, path: str, body: dict | None = None):
code, text = request(method, path, body)
if code >= 400:
raise RuntimeError(f"{method} {path} → HTTP {code}: {text[:500]}")
if not text.strip():
return None
return json.loads(text)
def main(argv: list[str]) -> int:
"""CLI: forge_api.py METHOD PATH [json-body] → prints response body"""
if len(argv) < 3:
print("usage: forge_api.py METHOD /path [json-body]", file=sys.stderr)
return 2
method, path = argv[1], argv[2]
body = json.loads(argv[3]) if len(argv) > 3 else None
code, text = request(method, path, body)
sys.stdout.write(text)
return 0 if code < 400 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Resolve Gatekeep config files for remote-referenceable Action installs.
# Prefer optional consumer override dir; else bundled defaults under action_path.
#
# Expects: GATEKEEP_ROOT, GATEKEEP_ACTION, CONFIG_DIR (may be empty)
# Usage: source "$GATEKEEP_ACTION/scripts/lib-config.sh"
# cfg=$(gatekeep_config_file models.json)
gatekeep_config_file() {
local name="$1"
local root="${GATEKEEP_ROOT:-.}"
local action="${GATEKEEP_ACTION:-.}"
local cdir="${CONFIG_DIR:-}"
# Optional consumer override (only if config-dir set and file exists)
if [[ -n "$cdir" && -f "$root/$cdir/$name" ]]; then
printf '%s\n' "$root/$cdir/$name"
return 0
fi
# Bundled defaults shipped inside the Action (remote uses: / copy-in)
if [[ -f "$action/defaults/$name" ]]; then
printf '%s\n' "$action/defaults/$name"
return 0
fi
# Legacy pack layouts (dev / old copy-in)
if [[ -f "$action/../config/$name" ]]; then
printf '%s\n' "$action/../config/$name"
return 0
fi
if [[ -f "$action/../defaults/$name" ]]; then
printf '%s\n' "$action/../defaults/$name"
return 0
fi
echo "gatekeep: missing config $name (no consumer override, no Action defaults)" >&2
return 1
}
# Prefer providers.yaml, then providers.yml, then legacy providers.json / models.json
gatekeep_providers_config() {
local f
for f in providers.yaml providers.yml providers.json models.json; do
if fpath="$(gatekeep_config_file "$f" 2>/dev/null)"; then
printf '%s\n' "$fpath"
return 0
fi
done
echo "gatekeep: missing providers.yaml (or legacy providers.json / models.json)" >&2
return 1
}
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* Merge parallel model outputs into agreed findings + disputed + fail-closed PR risk tier.
* Zero npm dependencies (Node 20 stdlib only).
*
* Finding-level severity (Critical/High/Medium/Low) is distinct from PR risk tier.
* Every finding carries: severity, exact location, why_it_matters.
*
* Agreement: fingerprint must appear in >= agreementMin distinct models (default 2).
* Security: agreed security findings block; scan hard-fail always blocks.
*/
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const args = parseArgs(process.argv.slice(2));
const dir = args.dir;
const floor = args.floor || "low";
const agreementMin = Number(args["agreement-min"] || 2);
const scanHardFail = args["scan-hard-fail"] === "true";
const outPath = args.out;
const tierRank = { low: 0, medium: 1, high: 2 };
const sevRank = { Critical: 0, High: 1, Medium: 2, Low: 3 };
const maxTier = (a, b) => (tierRank[a] >= tierRank[b] ? a : b);
const normTier = (t) => {
const s = String(t || "").toLowerCase();
if (s === "low" || s === "medium" || s === "high") return s;
if (s === "easy") return "low";
if (s === "hard") return "high";
return "high";
};
const files = readdirSync(dir).filter((f) => f.startsWith("model-") && f.endsWith(".json"));
const models = [];
for (const f of files) {
try {
const j = JSON.parse(readFileSync(join(dir, f), "utf8"));
if (j.error || j.parse_error) {
models.push({
model: j._model || f,
error: j.error || j.parse_error,
findings: [],
risk_tier: "high",
});
continue;
}
models.push({
model: j._model || f,
risk_tier: normTier(j.risk_tier),
summary: j.summary || "",
rationale: j.rationale || "",
findings: Array.isArray(j.findings) ? j.findings : [],
human_focus: Array.isArray(j.human_focus) ? j.human_focus : [],
});
} catch (e) {
models.push({ model: f, error: String(e), findings: [], risk_tier: "high" });
}
}
function fingerprint(f) {
const path = String(f.path || "").replace(/\\/g, "/").toLowerCase();
const lineBucket = f.line ? String(Math.floor(Number(f.line) / 5) * 5) : "file";
const kind = String(f.kind || "other").toLowerCase();
const title = String(f.title || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.split(/\s+/)
.slice(0, 8)
.join(" ");
return `${kind}|${path}|${lineBucket}|${title}`;
}
function normSeverity(raw, kind, tag) {
const s = String(raw || "").trim();
const map = {
critical: "Critical",
crit: "Critical",
high: "High",
medium: "Medium",
med: "Medium",
low: "Low",
important: "High",
nit: "Low",
};
if (map[s.toLowerCase()]) return map[s.toLowerCase()];
// Infer from kind/tag when model omitted severity
if (String(kind).toLowerCase() === "security") return "Critical";
if (tag === "Important") return "High";
if (tag === "Nit") return "Low";
if (tag === "Pre-existing") return "Medium";
return "Medium";
}
function formatLocation(path, line, lineEnd) {
const p = String(path || "").trim() || "(unknown file)";
if (line == null || line === "") return p;
if (lineEnd != null && Number(lineEnd) > Number(line)) return `${p}:${line}-${lineEnd}`;
return `${p}:${line}`;
}
function normalizeFinding(f, model) {
const kind = ["security", "correctness", "behavior", "other"].includes(String(f.kind).toLowerCase())
? String(f.kind).toLowerCase()
: "other";
let tag = ["Important", "Nit", "Pre-existing"].includes(f.tag) ? f.tag : "Important";
if (f.pre_existing === true) tag = "Pre-existing";
const severity = normSeverity(f.severity, kind, tag);
const path = String(f.path || "");
const line = typeof f.line === "number" ? f.line : undefined;
const line_end = typeof f.line_end === "number" ? f.line_end : undefined;
const why =
String(f.why_it_matters || f.why || f.impact || "").trim() ||
// Fall back: last sentence-ish of body, else body itself
(() => {
const body = String(f.body || "").trim();
if (!body) return "Impact not spelled out — treat as unresolved until clarified.";
const parts = body.split(/\n+/).map((x) => x.trim()).filter(Boolean);
return parts[parts.length - 1] || body;
})();
const title = String(f.title || "Finding");
const body = String(f.body || "");
return {
path,
line,
line_end,
location: formatLocation(path, line, line_end),
severity,
tag,
kind,
title,
body,
why_it_matters: why,
blocking: Boolean(f.blocking) || kind === "security" || severity === "Critical",
models: [model],
fingerprint: fingerprint({ ...f, kind, title }),
};
}
function preferSeverity(a, b) {
return (sevRank[a] ?? 9) <= (sevRank[b] ?? 9) ? a : b;
}
/** @type {Map<string, any>} */
const bucket = new Map();
for (const m of models) {
for (const raw of m.findings) {
const n = normalizeFinding(raw, m.model);
const prev = bucket.get(n.fingerprint);
if (!prev) {
bucket.set(n.fingerprint, n);
} else {
if (!prev.models.includes(m.model)) prev.models.push(m.model);
prev.severity = preferSeverity(prev.severity, n.severity);
if (n.tag === "Important") prev.tag = "Important";
if (n.tag === "Pre-existing" && prev.tag !== "Important") prev.tag = "Pre-existing";
if ((n.why_it_matters || "").length > (prev.why_it_matters || "").length) {
prev.why_it_matters = n.why_it_matters;
}
if ((n.body || "").length > (prev.body || "").length) prev.body = n.body;
if (n.blocking) prev.blocking = true;
if (!prev.line && n.line) prev.line = n.line;
if (!prev.line_end && n.line_end) prev.line_end = n.line_end;
prev.location = formatLocation(prev.path, prev.line, prev.line_end);
}
}
}
const agreed = [];
const disputed = [];
for (const f of bucket.values()) {
if (f.models.length >= agreementMin) agreed.push(f);
else disputed.push(f);
}
const bySeverity = (a, b) => (sevRank[a.severity] ?? 9) - (sevRank[b.severity] ?? 9);
agreed.sort(bySeverity);
disputed.sort(bySeverity);
let risk_tier = floor;
for (const m of models) risk_tier = maxTier(risk_tier, m.risk_tier);
for (const f of agreed) {
if (f.kind === "security" || f.severity === "Critical") risk_tier = maxTier(risk_tier, "high");
else if (f.severity === "High" || f.tag === "Important") risk_tier = maxTier(risk_tier, "medium");
}
if (scanHardFail) risk_tier = maxTier(risk_tier, "high");
const openSecurityBlocking =
scanHardFail || agreed.some((f) => (f.kind === "security" || f.severity === "Critical") && f.blocking);
const result = {
risk_tier,
policy_floor: floor,
agreement_min: agreementMin,
models: models.map((m) => ({
id: m.model,
risk_tier: m.risk_tier,
finding_count: m.findings.length,
error: m.error || null,
})),
agreed_findings: agreed,
disputed_findings: disputed,
open_security_blocking: openSecurityBlocking,
scan_hard_fail: scanHardFail,
human_focus: [...new Set(models.flatMap((m) => m.human_focus || []))].slice(0, 12),
summaries: models.map((m) => ({ model: m.model, summary: m.summary, rationale: m.rationale })),
};
writeFileSync(outPath, JSON.stringify(result, null, 2));
process.stdout.write(
JSON.stringify({
risk_tier: result.risk_tier,
security_blocking: openSecurityBlocking,
agreed_count: agreed.length,
disputed_count: disputed.length,
}) + "\n",
);
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const val = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
out[key] = val;
}
}
return out;
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# Deterministic fail-closed policy floor + lightweight security scan.
set -euo pipefail
ROOT="${GATEKEEP_ROOT:-.}"
ACTION="${GATEKEEP_ACTION:-.}"
OUT="$ROOT/.gatekeep/out"
mkdir -p "$OUT"
# shellcheck source=lib-config.sh
source "$ACTION/scripts/lib-config.sh"
POLICY="$(gatekeep_config_file policy.json)"
EVENT="${GITHUB_EVENT_PATH:-}"
if [[ -z "$EVENT" || ! -f "$EVENT" ]]; then
echo "GITHUB_EVENT_PATH missing" >&2
exit 1
fi
PR_NUMBER=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pull_request"]["number"])' "$EVENT")
REPO="${GITHUB_REPOSITORY:?}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
FORGE="$ACTION/scripts/forge_api.py"
python3 - "$FORGE" "$OWNER" "$NAME" "$PR_NUMBER" "$OUT" <<'PY'
import json, pathlib, sys, subprocess
forge, owner, name, pr, out = sys.argv[1:6]
out = pathlib.Path(out)
def api(method, path):
p = subprocess.run(
[sys.executable, forge, method, path],
capture_output=True, text=True,
)
if p.returncode != 0:
return None
try:
return json.loads(p.stdout) if p.stdout.strip() else None
except Exception:
return None
files_data = api("GET", f"/repos/{owner}/{name}/pulls/{pr}/files") or []
# Gitea may paginate poorly; accept list
if isinstance(files_data, dict):
files_data = files_data.get("files") or []
filenames = []
add = delete = 0
for f in files_data:
filenames.append(f.get("filename") or f.get("name") or "")
add += int(f.get("additions") or 0)
delete += int(f.get("deletions") or 0)
filenames = [f for f in filenames if f]
(out / "changed-files.txt").write_text("\n".join(filenames) + ("\n" if filenames else ""))
pr_data = api("GET", f"/repos/{owner}/{name}/pulls/{pr}") or {}
if not add and not delete:
add = int(pr_data.get("additions") or 0)
delete = int(pr_data.get("deletions") or 0)
labels = []
for l in pr_data.get("labels") or []:
if isinstance(l, dict):
labels.append(l.get("name") or "")
else:
labels.append(str(l))
head_sha = ((pr_data.get("head") or {}).get("sha")) or ""
ci = "unknown"
if head_sha:
st = api("GET", f"/repos/{owner}/{name}/commits/{head_sha}/statuses") or []
# Gitea returns list; derive combined
if isinstance(st, list) and st:
states = [s.get("state") for s in st]
if any(s == "failure" or s == "error" for s in states):
ci = "failure"
elif any(s == "pending" for s in states):
ci = "pending"
elif all(s == "success" for s in states):
ci = "success"
else:
ci = states[0] or "unknown"
elif isinstance(st, dict):
ci = st.get("state") or "unknown"
meta = {"additions": add, "deletions": delete, "labels": labels, "ci": ci, "head_sha": head_sha}
(out / "pr-meta.json").write_text(json.dumps(meta, indent=2))
print(json.dumps({"files": len(filenames), "add": add, "del": delete, "ci": ci}))
PY
ADD=$(python3 -c 'import json; print(json.load(open("'"$OUT"'/pr-meta.json")).get("additions",0))')
DEL=$(python3 -c 'import json; print(json.load(open("'"$OUT"'/pr-meta.json")).get("deletions",0))')
LABELS=$(python3 -c 'import json; print(",".join(json.load(open("'"$OUT"'/pr-meta.json")).get("labels") or []))')
CI_STATE=$(python3 -c 'import json; print(json.load(open("'"$OUT"'/pr-meta.json")).get("ci","unknown"))')
python3 - "$POLICY" "$OUT/changed-files.txt" "$ADD" "$DEL" "$LABELS" "$CI_STATE" "$OUT" <<'PY'
import json, fnmatch, sys, pathlib
policy = json.load(open(sys.argv[1]))
files = [l.strip() for l in open(sys.argv[2]) if l.strip()]
add, delete = int(sys.argv[3] or 0), int(sys.argv[4] or 0)
labels = [x.strip().lower() for x in (sys.argv[5] or "").split(",") if x.strip()]
ci = sys.argv[6]
out = pathlib.Path(sys.argv[7])
rank = {"low": 0, "medium": 1, "high": 2}
floor = "low"
reasons = []
def bump(t, why):
global floor
if rank[t] > rank[floor]:
floor = t
reasons.append(why)
for lab in labels:
if lab in [x.lower() for x in policy.get("force_high_labels", [])]:
bump("high", f"label forces high: {lab}")
if lab in [x.lower() for x in policy.get("force_medium_labels", [])]:
bump("medium", f"label forces medium: {lab}")
def match(path, glob):
g = glob.replace("**/", "*").replace("**", "*")
return fnmatch.fnmatch(path, glob) or fnmatch.fnmatch(path, g) or fnmatch.fnmatch(path.split("/")[-1], glob)
for f in files:
for g in policy.get("high_path_globs", []):
if match(f, g):
bump("high", f"high-path {f} ~ {g}")
for g in policy.get("medium_path_globs", []):
if match(f, g):
bump("medium", f"medium-path {f} ~ {g}")
if len(files) > int(policy.get("low_max_files", 8)):
bump("medium", f"file count {len(files)} > low cap")
if add + delete > int(policy.get("low_max_diff_lines", 200)):
bump("medium", f"diff lines {add+delete} > low cap")
if ci in ("failure", "error"):
bump("high", f"CI status {ci}")
seen=set(); uniq=[]
for r in reasons:
if r not in seen:
seen.add(r); uniq.append(r)
result = {"floor": floor, "reasons": uniq, "files": files, "additions": add, "deletions": delete, "labels": labels, "ci": ci}
(out / "floor.json").write_text(json.dumps(result, indent=2))
print(floor)
print("---")
print("\n".join(uniq))
PY
FLOOR_JSON="$OUT/floor.json"
FLOOR=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["floor"])' "$FLOOR_JSON")
REASONS=$(python3 -c 'import json,sys; print(" | ".join(json.load(open(sys.argv[1]))["reasons"]))' "$FLOOR_JSON")
SCAN_OUT="$OUT/security-scan.json"
bash "$ACTION/scripts/security-scan.sh" "$OUT/changed-files.txt" "$SCAN_OUT" || true
SCAN_HARD=$(python3 -c 'import json,sys; print("true" if json.load(open(sys.argv[1])).get("hard_fail") else "false")' "$SCAN_OUT")
if [[ "$SCAN_HARD" == "true" && "$FLOOR" != "high" ]]; then
FLOOR=high
REASONS="${REASONS} | deterministic security scan hard-fail"
python3 - "$FLOOR_JSON" <<'PY'
import json,sys
p=json.load(open(sys.argv[1]))
p["floor"]="high"
p["reasons"].append("deterministic security scan hard-fail")
json.dump(p, open(sys.argv[1],"w"), indent=2)
PY
fi
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "floor=$FLOOR" >> "$GITHUB_OUTPUT"
echo "reasons=$REASONS" >> "$GITHUB_OUTPUT"
echo "scan_hard_fail=$SCAN_HARD" >> "$GITHUB_OUTPUT"
fi
echo "Policy floor: $FLOOR (scan_hard_fail=$SCAN_HARD)" >&2
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
/**
* Publish Gatekeep summary + agreed inline comments.
* Forge-agnostic (GitHub + Gitea) via fetch — no gh CLI required.
*/
import { readFileSync, writeFileSync } from "node:fs";
const mergedPath = process.argv[2];
const floorPath = process.argv[3];
const scanPath = process.argv[4];
const merged = JSON.parse(readFileSync(mergedPath, "utf8"));
const floor = JSON.parse(readFileSync(floorPath, "utf8"));
const scan = scanPath ? JSON.parse(readFileSync(scanPath, "utf8")) : { hits: [] };
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
const pr = event.pull_request;
const repo = process.env.GITHUB_REPOSITORY;
const [owner, name] = repo.split("/");
const token =
process.env.GITHUB_TOKEN || process.env.GITEA_TOKEN || process.env.GH_TOKEN || "";
const marker = "<!-- gatekeep-review -->";
const checkName = "gatekeep / security";
const apiBase = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
const gitea = !apiBase.includes("api.github.com");
const SEV_EMOJI = { Critical: "🔴", High: "🟠", Medium: "🟡", Low: "🟢" };
async function forgeApi(method, path, body) {
const p = path.startsWith("/") ? path : `/${path}`;
const url = `${apiBase}${p}`;
const headers = {
Accept: gitea ? "application/json" : "application/vnd.github+json",
"User-Agent": "gatekeep-action",
Authorization: gitea ? `token ${token}` : `Bearer ${token}`,
};
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${method} ${p} → HTTP ${res.status}: ${text.slice(0, 400)}`);
}
return text.trim() ? JSON.parse(text) : null;
}
function approvals(tier) {
if (tier === "low")
return "Looks low-risk — no human needed once Gatekeep + security are green (auto-merge only if the kill switch is on).";
if (tier === "medium") return "Needs **1 human** look. Bot thumbs-up don't count as that look.";
return "Needs **2 human** approvals. Bots never fill those seats.";
}
function loc(f) {
return f.location || (f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : "(no location)");
}
function formatFindingBlock(f, { disputed = false } = {}) {
const sev = f.severity || "Medium";
const emoji = SEV_EMOJI[sev] || "⚪";
const pre = f.tag === "Pre-existing" ? " · pre-existing" : "";
const block = f.blocking ? " · blocking" : "";
const dispute = disputed ? " · disputed (not inlined)" : "";
const tagNote = f.tag && f.tag !== "Important" ? ` · ${f.tag}` : "";
const why = f.why_it_matters || "Impact not spelled out — please clarify before dismissing.";
const detail = (f.body || "").trim();
const detailBit = detail && detail !== why ? `\n\n${detail}` : "";
return (
`#### ${emoji} ${sev}${pre}${block}${dispute}${tagNote}: ${f.title}\n` +
`- **Where:** \`${loc(f)}\`\n` +
`- **Why it matters:** ${why}` +
detailBit +
`\n- _Models:_ ${f.models.join(", ")}`
);
}
function formatInline(f) {
const sev = f.severity || "Medium";
const emoji = SEV_EMOJI[sev] || "⚪";
const pre = f.tag === "Pre-existing" ? " (pre-existing)" : "";
const why = f.why_it_matters || "Impact not spelled out — please clarify before dismissing.";
const detail = (f.body || "").trim();
let out =
`### ${emoji} ${sev}${pre} — ${f.title}\n\n` +
`**Where:** \`${loc(f)}\`\n\n` +
`**Why it matters:** ${why}\n`;
if (detail && detail !== why) out += `\n${detail}\n`;
out +=
`\n---\n` +
`_Agreed by ${f.models.join(", ")}. Fix it, or reply \`dismissed: <reason>\` and resolve the thread._\n` +
`_No chat commands — Gatekeep doesn't take @-mentions._`;
return out;
}
const sortedAgreed = [...(merged.agreed_findings || [])];
const sortedDisputed = [...(merged.disputed_findings || [])];
const agreedList =
sortedAgreed.length === 0
? "_Nothing the models agreed on — nice, or spooky. Glance at disputed below._"
: sortedAgreed.map((f) => formatFindingBlock(f)).join("\n\n");
const disputedList =
sortedDisputed.length === 0
? "_No disagreements worth parking._"
: sortedDisputed.map((f) => formatFindingBlock(f, { disputed: true })).join("\n\n");
const modelRows = merged.models
.map(
(m) =>
`| \`${m.id}\` | \`${m.risk_tier}\` | ${m.finding_count}${m.error ? ` (err: ${m.error})` : ""} |`,
)
.join("\n");
const critCount = sortedAgreed.filter((f) => f.severity === "Critical").length;
const highCount = sortedAgreed.filter((f) => f.severity === "High").length;
const body = `${marker}
## Gatekeep review
Hey — here's the multi-model pass. **Severity first** so you can skim Critical → Low in seconds.
| | |
| --- | --- |
| **PR risk tier** | \`${merged.risk_tier}\` |
| Policy floor | \`${merged.policy_floor}\` |
| Agreement bar | ≥${merged.agreement_min} models |
| Agreed / disputed | ${sortedAgreed.length} / ${sortedDisputed.length} |
| Critical / High (agreed) | ${critCount} / ${highCount} |
${approvals(merged.risk_tier)}
### Models (parallel)
| Model | Their PR tier | Findings |
| --- | --- | --- |
${modelRows}
### Agreed findings
Sorted by severity. Each one spells out **severity**, **where**, and **why it matters**.
Fix or dismiss with \`dismissed: <reason>\` (then resolve the thread). Gatekeep has **no** chat / @-command surface.
${agreedList}
### Disputed (summary only — not posted inline)
One model saw it; others didn't. Useful signal, not a merge blocker by itself.
${disputedList}
### Deterministic security scan
${(scan.hits || []).length ? (scan.hits || []).map((h) => `- ${h}`).join("\n") : "_No hard-fail hits._"}
### Policy floor signals
${(floor.reasons || []).map((r) => `- ${r}`).join("\n") || "- none"}
### Still on humans
${(merged.human_focus || []).map((h) => `- ${h}`).join("\n") || "- taste, architecture, billing/CI/auth, reading the evidence"}
---
Missed something real? Open a **gatekeep-incident** issue — that's how it becomes an eval.
_Inline comments only when models agree. PR tier is fail-closed. Findings lead with severity + location + why._
`;
async function main() {
const comments =
(await forgeApi(
"GET",
`/repos/${owner}/${name}/issues/${pr.number}/comments?limit=100&per_page=100`,
)) || [];
const list = Array.isArray(comments) ? comments : [];
const existing = list.find((c) => (c.body || "").includes(marker));
if (existing) {
await forgeApi("PATCH", `/repos/${owner}/${name}/issues/comments/${existing.id}`, { body });
} else {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, { body });
}
const inline = sortedAgreed
.filter((f) => f.path && f.line && f.severity !== "Low" && f.tag !== "Nit")
.slice(0, 30)
.map((f) => ({
path: f.path,
// Gitea uses old_position/new_position in some versions; line works on GitHub
line: f.line,
body: formatInline(f),
}));
if (inline.length) {
try {
await forgeApi("POST", `/repos/${owner}/${name}/pulls/${pr.number}/reviews`, {
commit_id: pr.head.sha,
event: "COMMENT",
body: "Gatekeep agreed findings (severity · where · why). Fix or dismiss — no @-commands.",
comments: inline,
});
} catch (e) {
console.error("Inline review failed; falling back to issue comments:", e.message);
for (const f of inline) {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, {
body: f.body + `\n\n_\`${f.path}:${f.line}\`_`,
});
}
}
}
for (const f of sortedAgreed) {
if (
(f.severity === "Critical" || f.severity === "High" || f.tag === "Important") &&
!f.line &&
f.path
) {
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/comments`, {
body: formatInline(f) + `\n\n_File-level (no single line) on \`${f.path}\`._`,
});
}
}
const want = `risk:${merged.risk_tier}`;
for (const lab of ["risk:low", "risk:medium", "risk:high"]) {
if (lab !== want) {
try {
await forgeApi(
"DELETE",
`/repos/${owner}/${name}/issues/${pr.number}/labels/${encodeURIComponent(lab)}`,
);
} catch {
/* label may not exist */
}
}
}
try {
// Gitea expects { labels: ["name"] }; GitHub same for issues API
await forgeApi("POST", `/repos/${owner}/${name}/issues/${pr.number}/labels`, {
labels: [want],
});
} catch (e) {
console.error("label:", e.message);
}
const conclusion = merged.open_security_blocking ? "failure" : "success";
const statusState = merged.open_security_blocking ? "failure" : "success";
const desc = merged.open_security_blocking
? "Open security / Critical findings — merge blocked"
: `PR risk ${merged.risk_tier} — no open security blockers`;
// Prefer Checks API (GitHub); fall back to commit status (Gitea-friendly)
try {
await forgeApi("POST", `/repos/${owner}/${name}/check-runs`, {
name: checkName,
head_sha: pr.head.sha,
status: "completed",
conclusion,
output: {
title: desc,
summary: desc,
},
});
} catch {
try {
await forgeApi("POST", `/repos/${owner}/${name}/statuses/${pr.head.sha}`, {
state: statusState,
context: checkName,
description: desc.slice(0, 140),
});
} catch (e2) {
console.error("status/check publish failed:", e2.message);
}
}
if (process.env.GITHUB_OUTPUT) {
writeFileSync(
process.env.GITHUB_OUTPUT,
[
`risk_tier=${merged.risk_tier}`,
`security_blocking=${merged.open_security_blocking}`,
`agreed_count=${sortedAgreed.length}`,
].join("\n") + "\n",
{ flag: "a" },
);
}
if (merged.open_security_blocking) {
console.error("Security blocking — exiting non-zero for job red X");
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
Resolve Gatekeep providers from YAML (or legacy JSON).
Each provider entry needs:
key: FREE-FORM secret/env var name (NO required gatekeep_ prefix)
protocol: openai | openai-completions | openai-responses | anthropic
Optional:
base_url, models (defaults applied for known provider ids)
Discovers model lists from the provider API; validates requested names; writes
provider-plan.json for mixed parallel review.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
PROTOCOLS = ("openai", "openai-completions", "openai-responses", "anthropic")
# Wire aliases used by call-model.sh
PROTOCOL_WIRE = {
"openai": "openai-completions",
"openai-completions": "openai-completions",
"openai-compatible": "openai-completions", # legacy
"openai-responses": "openai-responses",
"anthropic": "anthropic",
}
KNOWN_DEFAULTS = {
"openrouter": {
"base_url": "https://openrouter.ai/api/v1",
"protocol": "openai",
},
"anthropic": {
"base_url": "https://api.anthropic.com",
"protocol": "anthropic",
},
"openai": {
"base_url": "https://api.openai.com/v1",
"protocol": "openai",
},
}
def die(msg: str, code: int = 1) -> None:
print(f"gatekeep: {msg}", file=sys.stderr)
sys.exit(code)
def load_raw(path: Path) -> dict:
text = path.read_text()
if path.suffix.lower() in (".yaml", ".yml"):
try:
import yaml # type: ignore
except ImportError:
die("PyYAML required to load providers.yaml (pip install pyyaml)")
raw = yaml.safe_load(text) or {}
else:
raw = json.loads(text)
# Legacy models.json → single openrouter provider
if "providers" not in raw and "models" in raw:
base = raw.get("openrouter_base_url") or KNOWN_DEFAULTS["openrouter"]["base_url"]
raw = {
"agreement_min": raw.get("agreement_min", 2),
"temperature": raw.get("temperature", 0),
"max_tokens": raw.get("max_tokens", 4096),
"providers": {
"openrouter": {
"key": "OPENROUTER_API_KEY",
"protocol": "openai",
"base_url": base,
"models": raw["models"],
}
},
}
# Legacy JSON providers without `key`
providers = raw.get("providers") or {}
for pid, pconf in list(providers.items()):
if not isinstance(pconf, dict):
continue
if "key" not in pconf:
# migrate old shape: invent free-form env name suggestion from provider id
# (not forcing gatekeep_ prefix)
pconf = {**pconf, "key": f"{pid.upper()}_API_KEY"}
if pconf.get("protocol") == "openai-compatible":
pconf["protocol"] = "openai"
providers[pid] = pconf
raw["providers"] = providers
if not providers:
die(f"no providers in {path}")
return raw
def secret_value(key_name: str) -> str:
"""Look up FREE-FORM secret name in the process environment. No prefix rules."""
name = (key_name or "").strip()
if not name:
return ""
val = os.environ.get(name, "")
if val and val.strip():
return val.strip()
return ""
def http_json(url: str, headers: dict) -> dict | list:
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
def normalize_protocol(p: str) -> str:
p = (p or "").strip().lower()
if p not in PROTOCOL_WIRE:
die(f"unknown protocol {p!r} (want {' | '.join(PROTOCOLS)})")
return p
def wire_protocol(p: str) -> str:
return PROTOCOL_WIRE[normalize_protocol(p)]
def discover_model_ids(base_url: str, protocol: str, api_key: str) -> set[str]:
base = base_url.rstrip("/")
wire = wire_protocol(protocol)
ids: set[str] = set()
try:
if wire in ("openai-completions", "openai-responses"):
data = http_json(
f"{base}/models",
{"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
for m in data.get("data") or []:
mid = m.get("id")
if mid:
ids.add(mid)
elif wire == "anthropic":
data = http_json(
f"{base}/v1/models",
{
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
)
for m in data.get("data") or []:
mid = m.get("id")
if mid:
ids.add(mid)
else:
die(f"unhandled protocol wire {wire!r}")
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:400]
die(f"model discovery failed for {base}: HTTP {e.code} {body}")
except Exception as e:
die(f"model discovery failed for {base}: {e}")
return ids
def enrich_provider(pid: str, pconf: dict) -> dict:
known = KNOWN_DEFAULTS.get(pid, {})
out = {**pconf}
if not out.get("base_url"):
if known.get("base_url"):
out["base_url"] = known["base_url"]
else:
die(f"provider {pid}: base_url required (no default for this id)")
if not out.get("protocol"):
out["protocol"] = known.get("protocol") or "openai"
out["protocol"] = normalize_protocol(out["protocol"])
if not out.get("key"):
die(f"provider {pid}: `key` (free-form secret/env name) is required")
if not isinstance(out.get("models"), list) or not out["models"]:
die(f"provider {pid}: `models` list required (names validated against provider API)")
return out
def main() -> None:
if len(sys.argv) < 3:
die("usage: resolve-providers.py <providers.yaml|json> <out-plan.json> [override-models-csv]")
cfg_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
override_csv = sys.argv[3] if len(sys.argv) > 3 else ""
cfg = load_raw(cfg_path)
providers_in = cfg["providers"]
providers: dict[str, dict] = {
pid: enrich_provider(pid, pconf) for pid, pconf in providers_in.items()
}
override_items: list[tuple[str | None, str]] = []
if override_csv.strip():
for part in override_csv.split(","):
part = part.strip()
if not part:
continue
head, _, rest = part.partition(":")
if rest and head in providers:
override_items.append((head, rest))
else:
override_items.append((None, part))
requested: list[dict] = []
if override_items:
catalog: dict[str, list[str]] = {}
for pid, pconf in providers.items():
for m in pconf["models"]:
catalog.setdefault(m, []).append(pid)
for prov, model in override_items:
if prov:
requested.append({"provider": prov, "model": model})
else:
hits = catalog.get(model) or []
if len(hits) == 1:
requested.append({"provider": hits[0], "model": model})
elif len(hits) > 1:
die(f"model {model!r} under multiple providers {hits}; use provider:model")
elif len(providers) == 1:
requested.append({"provider": next(iter(providers)), "model": model})
else:
die(f"model {model!r} not in config; use provider:model")
else:
for pid, pconf in providers.items():
for m in pconf["models"]:
requested.append({"provider": pid, "model": m})
seen: set[tuple[str, str]] = set()
uniq: list[dict] = []
for r in requested:
key = (r["provider"], r["model"])
if key not in seen:
seen.add(key)
uniq.append(r)
requested = uniq
if len(requested) < 2:
die(f"need 2–3 models for parallel review; got {len(requested)}")
if len(requested) > 3:
print(f"gatekeep: truncating to first 3 of {len(requested)} models", file=sys.stderr)
requested = requested[:3]
provider_meta: dict[str, dict] = {}
for pid in {r["provider"] for r in requested}:
pconf = providers[pid]
key_name = str(pconf["key"])
api_key = secret_value(key_name)
if not api_key:
die(
f"missing secret/env for provider {pid!r}: expected env var named {key_name!r} "
f"(from providers.*.key — free-form; set it on the workflow step env from secrets)."
)
available = discover_model_ids(pconf["base_url"], pconf["protocol"], api_key)
if not available:
die(f"provider {pid}: model list empty — check base_url/protocol/key")
provider_meta[pid] = {
"base_url": pconf["base_url"].rstrip("/"),
"protocol": pconf["protocol"],
"wire_protocol": wire_protocol(pconf["protocol"]),
"key_name": key_name,
"api_key": api_key,
"available_count": len(available),
"_available": available,
}
plan_models = []
for r in requested:
pid, model = r["provider"], r["model"]
avail = provider_meta[pid]["_available"]
if model not in avail:
sample = sorted(avail)[:8]
die(
f"model {model!r} not available on provider {pid} "
f"({provider_meta[pid]['available_count']} listed). Examples: {sample}"
)
plan_models.append(
{
"provider": pid,
"model": model,
"base_url": provider_meta[pid]["base_url"],
"protocol": provider_meta[pid]["protocol"],
"wire_protocol": provider_meta[pid]["wire_protocol"],
"key_name": provider_meta[pid]["key_name"],
}
)
secrets_path = out_path.with_suffix(".secrets.json")
secrets_path.write_text(
json.dumps({pid: provider_meta[pid]["api_key"] for pid in provider_meta}, indent=2)
)
os.chmod(secrets_path, 0o600)
plan = {
"agreement_min": int(cfg.get("agreement_min", 2)),
"temperature": cfg.get("temperature", 0),
"max_tokens": int(cfg.get("max_tokens", 4096)),
"models": plan_models,
"secrets_file": str(secrets_path),
"providers_used": sorted(provider_meta.keys()),
}
out_path.write_text(json.dumps(plan, indent=2))
print(
json.dumps(
{
"providers": plan["providers_used"],
"models": [f"{m['provider']}:{m['model']}" for m in plan_models],
"keys": {m["provider"]: m["key_name"] for m in plan_models},
"agreement_min": plan["agreement_min"],
}
),
flush=True,
)
if __name__ == "__main__":
main()
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# Orchestrate parallel multi-provider model calls → merge → publish.
set -euo pipefail
ROOT="${GATEKEEP_ROOT:-.}"
ACTION="${GATEKEEP_ACTION:-.}"
OUT="$ROOT/.gatekeep/out"
MODELS_DIR="$OUT/models"
mkdir -p "$MODELS_DIR"
EVENT="${GITHUB_EVENT_PATH:?}"
export GATEKEEP_ROOT ACTION CONFIG_DIR OUT
# shellcheck source=lib-config.sh
source "$ACTION/scripts/lib-config.sh"
if [[ "${SKIP_DRAFT:-true}" == "true" ]]; then
DRAFT=$(python3 -c 'import json,sys; print(str(json.load(open(sys.argv[1]))["pull_request"].get("draft", False)).lower())' "$EVENT")
if [[ "$DRAFT" == "true" ]]; then
echo "Draft PR — skip"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "risk_tier=skipped" >> "$GITHUB_OUTPUT"
echo "security_blocking=false" >> "$GITHUB_OUTPUT"
echo "agreed_count=0" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
fi
REPO="${GITHUB_REPOSITORY:?}"
PR=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pull_request"]["number"])' "$EVENT")
PROVIDERS_CFG="$(gatekeep_providers_config)"
PLAN="$OUT/provider-plan.json"
python3 "$ACTION/scripts/resolve-providers.py" \
"$PROVIDERS_CFG" \
"$PLAN" \
"${GATEKEEP_MODELS:-}"
AGREEMENT_MIN=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["agreement_min"])' "$PLAN")
export GATEKEEP_MAX_TOKENS
GATEKEEP_MAX_TOKENS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("max_tokens",4096))' "$PLAN")
SECRETS_FILE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["secrets_file"])' "$PLAN")
cleanup_secrets() { rm -f "$SECRETS_FILE"; }
trap cleanup_secrets EXIT
# Diff (truncated) — forge-agnostic (.diff URL)
: > "$OUT/diff.full"
SERVER="${GITHUB_SERVER_URL:-}"
TOK="${GITHUB_TOKEN:-${GITEA_TOKEN:-}}"
if [[ -n "$SERVER" ]]; then
curl -sS -H "Authorization: token ${TOK}" \
"${SERVER%/}/${REPO}/pulls/${PR}.diff" -o "$OUT/diff.full" 2>/dev/null || true
fi
if [[ ! -s "$OUT/diff.full" ]]; then
python3 "$ACTION/scripts/forge_api.py" GET "/repos/${REPO}/pulls/${PR}.diff" >"$OUT/diff.full" 2>/dev/null || true
fi
python3 -c 'import pathlib; p=pathlib.Path("'"$OUT"'/diff.full"); t=p.read_text(errors="replace"); pathlib.Path("'"$OUT"'/diff.txt").write_text(t if len(t)<=200000 else t[:200000]+"\n\n… [truncated]")'
FLOOR="${POLICY_FLOOR:-$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["floor"])' "$OUT/floor.json")}"
SCAN_HARD="${SCAN_HARD_FAIL:-false}"
export POLICY_FLOOR="$FLOOR" POLICY_REASONS="${POLICY_REASONS:-}"
python3 <<'PY'
import json, os, pathlib
out = pathlib.Path(os.environ["OUT"])
floor = os.environ.get("POLICY_FLOOR") or json.loads((out / "floor.json").read_text())["floor"]
reasons = os.environ.get("POLICY_REASONS", "")
event = json.loads(pathlib.Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
pr = event["pull_request"]
files = [l.strip() for l in (out / "changed-files.txt").read_text().splitlines() if l.strip()]
floor_j = json.loads((out / "floor.json").read_text())
diff = (out / "diff.txt").read_text() if (out / "diff.txt").exists() else ""
discover = json.loads((out / "discover.json").read_text()) if (out / "discover.json").exists() else {}
plan = json.loads((out / "provider-plan.json").read_text())
system = """You are one reviewer in Gatekeep, a multi-model PR review system for the repo owner's own projects.
Your job is useful review content that makes human triage cheap — not chat UX.
Return ONLY valid JSON (no markdown fences) with:
{
"risk_tier": "low|medium|high",
"summary": "short chatty overview",
"rationale": "why this PR risk tier",
"findings": [{
"severity": "Critical|High|Medium|Low",
"path": "file path",
"line": 1,
"line_end": null,
"tag": "Important|Nit|Pre-existing",
"kind": "security|correctness|behavior|other",
"title": "short headline",
"why_it_matters": "concrete impact if ignored",
"body": "optional extra detail in a friendly tone",
"blocking": false
}],
"human_focus": ["string"]
}
Finding rules (required on EVERY finding):
1. severity — Critical/High/Medium/Low so humans see critical vs minor instantly. (Distinct from PR risk_tier.)
2. exact location — path + line (use line_end for a range when needed).
3. why_it_matters — the impact/reason, not just \"please fix\".
Tone: clear and human; structured fields, not robotic checklist-speak.
tag is secondary (Important/Nit/Pre-existing); severity dominates.
Prefer real harm over style nits. Drop pure style. kind=security or severity=Critical ⇒ blocking true.
Pre-existing issues: still include severity + location + why if the PR touches or worsens them.
PR risk_tier rules:
- From the change (paths, blast radius, security, CI), not author trust.
- MUST be >= the provided policy_floor.
- Security checklist seed: hardcoded secrets/tokens; prompt injection via repo files; auth bypass; unauthorized API access; billing logic bypass; CI workflow tampering; unsafe deserialization; SQL/command injection.
- You never approve as a human. Bots never fill human slots.
- Do not invent chat commands or ask humans to @-mention you.
"""
user = {
"policy_floor": floor,
"policy_floor_reasons": [r.strip() for r in reasons.split("|") if r.strip()],
"discover": discover,
"reviewers": [f"{m['provider']}:{m['model']}" for m in plan.get("models", [])],
"pull_request": {
"title": pr.get("title"),
"body": pr.get("body") or "",
"author": (pr.get("user") or {}).get("login"),
"base": (pr.get("base") or {}).get("ref"),
"head": (pr.get("head") or {}).get("ref"),
"labels": [l.get("name") if isinstance(l, dict) else l for l in (pr.get("labels") or [])],
"ci_status": floor_j.get("ci"),
"changed_files": files,
"additions": floor_j.get("additions"),
"deletions": floor_j.get("deletions"),
},
"diff": diff,
}
json.dump({"system": system, "user": json.dumps(user, indent=2)}, open(out / "prompt.json", "w"))
print(f"Prompt ready; floor={floor}; plan={len(plan.get('models',[]))} models", flush=True)
PY
# Fire models in parallel (may be mixed providers)
pids=()
i=0
while IFS= read -r line; do
i=$((i + 1))
provider=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["provider"])')
model=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["model"])')
protocol=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["protocol"])')
base_url=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["base_url"])')
safe=$(echo "${provider}_${model}" | tr '/:' '__')
bash "$ACTION/scripts/call-model.sh" \
"$provider" "$model" "$protocol" "$base_url" \
"$OUT/prompt.json" "$MODELS_DIR/model-$i-$safe.json" "$SECRETS_FILE" &
pids+=($!)
done < <(python3 -c 'import json,sys; [print(json.dumps(m)) for m in json.load(open(sys.argv[1]))["models"]]' "$PLAN")
ec=0
for pid in "${pids[@]}"; do
wait "$pid" || ec=1
done
echo "Parallel model calls finished (wait_ec=$ec)" >&2
# Scrub secrets file from workspace artifact path if present
rm -f "$SECRETS_FILE"
node "$ACTION/scripts/merge-findings.js" \
--dir "$MODELS_DIR" \
--floor "$FLOOR" \
--agreement-min "$AGREEMENT_MIN" \
--scan-hard-fail "$SCAN_HARD" \
--out "$OUT/merged.json" | tee "$OUT/merge-meta.json"
set +e
node "$ACTION/scripts/publish.js" "$OUT/merged.json" "$OUT/floor.json" "$OUT/security-scan.json"
pub_ec=$?
set -e
META=$(cat "$OUT/merge-meta.json")
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
python3 -c 'import json,os,sys
m=json.loads(sys.argv[1])
open(os.environ["GITHUB_OUTPUT"],"a").write(
f"risk_tier={m[\"risk_tier\"]}\nsecurity_blocking={str(m[\"security_blocking\"]).lower()}\nagreed_count={m[\"agreed_count\"]}\n"
)' "$META"
fi
exit "$pub_ec"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Deterministic security checklist seed scanner (paths + diff hunks).
# Hits here are hard-fail regardless of model agreement (see policy.security_agreement).
set -euo pipefail
FILES_LIST="${1:?changed-files list}"
OUT="${2:?out json}"
ROOT="${GATEKEEP_ROOT:-.}"
REPO="${GITHUB_REPOSITORY:-}"
EVENT="${GITHUB_EVENT_PATH:-}"
hits=()
while IFS= read -r f; do
[[ -z "$f" ]] && continue
case "$f" in
.github/workflows/*|.github/actions/*)
hits+=("CI workflow tampering surface: $f")
;;
*secret*|*credential*|.env*|.env)
hits+=("Secrets/credential path touched: $f")
;;
esac
done < "$FILES_LIST"
# Diff scan for high-signal patterns (best-effort; forge-agnostic)
if [[ -n "$EVENT" && -f "$EVENT" && -n "$REPO" ]]; then
PR=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pull_request"]["number"])' "$EVENT")
ACTION_DIR="${GATEKEEP_ACTION:-$(cd "$(dirname "$0")/.." && pwd)}"
TMP=$(mktemp)
# Prefer .diff endpoint (works on Gitea + GitHub)
SERVER="${GITHUB_SERVER_URL:-}"
TOKEN="${GITHUB_TOKEN:-${GITEA_TOKEN:-}}"
if [[ -n "$SERVER" ]]; then
curl -sS -H "Authorization: token ${TOKEN}" \
"${SERVER%/}/${REPO}/pulls/${PR}.diff" -o "$TMP" 2>/dev/null \
|| curl -sS -H "Authorization: token ${TOKEN}" \
"${GITHUB_API_URL%/}/repos/${REPO}/pulls/${PR}.diff" -o "$TMP" 2>/dev/null \
|| true
fi
if [[ ! -s "$TMP" ]]; then
python3 "$ACTION_DIR/scripts/forge_api.py" GET "/repos/${REPO}/pulls/${PR}.diff" >"$TMP" 2>/dev/null || true
fi
DIFF=$(cat "$TMP" 2>/dev/null || true)
# Only look at added lines
ADDED=$(grep -E '^\+' "$TMP" | grep -vE '^\+\+\+' || true)
check() {
local name="$1" pat="$2"
if printf '%s\n' "$ADDED" | grep -Eiq -- "$pat"; then
hits+=("$name")
fi
}
check "Hardcoded secrets/tokens pattern" '(api[_-]?key|secret|token|password|private[_-]?key)\s*[:=]\s*['\''\"]?[A-Za-z0-9_\-]{16,}'
check "Prompt injection via repo instruction files" '(ignore (all )?(previous|above) instructions|you are now|system prompt)'
check "Possible SQL injection concat" '(execute\(|query\(|raw\().*\+|(SELECT|DROP|UNION).*\+'
check "Possible command injection" '(exec\(|spawn\(|os\.system\(|subprocess\.[a-z]+\([^)]*shell\s*=\s*True)'
check "Unsafe deserialization" '(pickle\.loads|yaml\.load\(|unserialize\(|ObjectInputStream)'
rm -f "$TMP"
fi
HARD=false
if ((${#hits[@]} > 0)); then
HARD=true
fi
python3 - "$OUT" "$HARD" "${hits[@]}" <<'PY'
import json,sys
out=sys.argv[1]
hard=sys.argv[2]=="true"
hits=sys.argv[3:]
json.dump({"hard_fail": hard, "hits": hits, "checklist": [
"hardcoded secrets/tokens",
"prompt injection via repo files",
"auth bypass",
"unauthorized API access",
"billing logic bypass",
"CI workflow tampering",
"unsafe deserialization",
"SQL/command injection"
]}, open(out,"w"), indent=2)
print(f"security-scan hard_fail={hard} hits={len(hits)}", file=sys.stderr)
PY
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Verify layer: discover or run caller-configured benchmarks vs thresholds.
# Architecture-level performance debt is out of scope — metrics only.
set -euo pipefail
ROOT="${GATEKEEP_ROOT:-.}"
ACTION="${GATEKEEP_ACTION:-.}"
OUT="$ROOT/.gatekeep/verify"
mkdir -p "$OUT"
# shellcheck source=lib-config.sh
source "$ACTION/scripts/lib-config.sh"
BENCH_CFG="$(gatekeep_config_file benchmarks.json)"
ENABLED=$(python3 -c 'import json,sys; print(str(json.load(open(sys.argv[1])).get("enabled", True)).lower())' "$BENCH_CFG")
if [[ "$ENABLED" != "true" ]]; then
echo "Benchmarks disabled" | tee "$OUT/result.txt"
exit 0
fi
python3 - "$BENCH_CFG" "$OUT" "$ROOT" <<'PY'
import json, os, pathlib, subprocess, time, re, sys
cfg = json.load(open(sys.argv[1]))
out = pathlib.Path(sys.argv[2])
root = pathlib.Path(sys.argv[3])
thr = cfg.get("thresholds") or {}
cmds = cfg.get("commands") or {}
discover = {}
dpath = root / ".gatekeep/out/discover.json"
if dpath.exists():
discover = json.loads(dpath.read_text())
def pick_command(kind_prefixes):
explicit = cmds.get("benchmark") or cmds.get(kind_prefixes[0].split(":")[0]) or ""
if explicit:
return explicit, "config"
for e in discover.get("entries") or []:
k = e.get("kind") or ""
if any(k.startswith(p) or p in k for p in kind_prefixes):
return e.get("command"), e.get("source")
return None, None
results = {"metrics": {}, "thresholds": thr, "ran": [], "skipped": [], "pass": True}
# Optional explicit startup / memory / fps harnesses — callers wire real collectors.
# Default: run discovered bench/test if present; parse common patterns when printed.
bench_cmd, src = pick_command(["npm:bench", "npm:benchmark", "make:bench", "make:benchmark", "cargo:bench"])
test_cmd, tsrc = pick_command(["npm:test", "make:test", "gradle:test", "cargo:test", "go:test", "pytest", "mvn:test"])
def run(label, cmd):
if not cmd:
results["skipped"].append(label)
return ""
results["ran"].append({"label": label, "command": cmd})
t0 = time.time()
p = subprocess.run(cmd, shell=True, cwd=root, capture_output=True, text=True)
dt = int((time.time() - t0) * 1000)
(out / f"{label}.stdout.txt").write_text(p.stdout[-50000:])
(out / f"{label}.stderr.txt").write_text(p.stderr[-20000:])
results["metrics"][f"{label}_exit"] = p.returncode
results["metrics"][f"{label}_wall_ms"] = dt
if p.returncode != 0:
results["pass"] = False
return p.stdout + "\n" + p.stderr
log = ""
if bench_cmd:
log += run("benchmark", bench_cmd)
elif test_cmd:
# Fall back: treat test suite wall time as coarse startup/regression signal only when no bench
log += run("verify_tests", test_cmd)
else:
results["skipped"].append("no discoverable benchmark/test command")
# Parse metrics if present in output
def grab(name, patterns):
for pat in patterns:
m = re.search(pat, log, re.I | re.M)
if m:
try:
results["metrics"][name] = float(m.group(1))
return
except Exception:
pass
grab("fps", [r"fps[=:\s]+([0-9.]+)", r"frames?/s[=:\s]+([0-9.]+)"])
grab("startup_ms", [r"startup[^0-9]*([0-9.]+)\s*ms", r"started in ([0-9.]+)\s*ms"])
grab("memory_mb", [r"memory[^0-9]*([0-9.]+)\s*mb", r"rss[=:\s]+([0-9.]+)\s*mb"])
# Threshold checks (null skips)
def check(metric, op, key):
lim = thr.get(key)
if lim is None:
return
if metric not in results["metrics"]:
results["skipped"].append(f"missing metric for {key}")
return
val = results["metrics"][metric]
ok = val <= lim if op == "max" else val >= lim
results["metrics"][f"{key}_ok"] = ok
if not ok:
results["pass"] = False
check("startup_ms", "max", "startup_ms_max")
check("memory_mb", "max", "memory_mb_max")
check("fps", "min", "fps_min")
(out / "benchmarks.json").write_text(json.dumps(results, indent=2))
print(json.dumps({"pass": results["pass"], "metrics": results["metrics"]}, indent=2))
if not results["pass"]:
sys.exit(1)
PY