Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/process-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ the durable index for the tooling; `docs/operator-backlog.md` tracks the human-o
must use an outcome-focused title, complete Summary and Verification evidence, and provide risk/rollback
evidence for clinical or operationally sensitive paths. UI changes require `verify:ui` evidence (or an
explicit reason it could not run), while clinical-risk changes must fully disposition the governance
checklist. The `pull_request_target` job checks out the exact base SHA, has read-only permissions, and
never executes PR-head code. Drafts remain non-blocking until marked ready; merge-queue runs emit the
checklist. The `pull_request_target` job checks out the trusted `github.workflow_sha` revision, has
read-only permissions, and never executes PR-head code. Drafts remain non-blocking until marked ready; merge-queue runs emit the
same stable `PR policy` check name.
- **Default-branch failure attribution** (`scripts/ci-triage.mjs`): triage now compares a failed PR only
with the latest completed run of the same workflow on `main`. It no longer samples the latest arbitrary
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"check:maintainability-budgets": "node scripts/check-maintainability-budgets.mjs",
"check:ci-scope": "node scripts/ci-change-scope.mjs --self-test",
"check:ci-triage": "node scripts/ci-triage.mjs --self-test",
"check:pr-policy": "node scripts/pr-policy.mjs --self-test",
"check:pr-policy": "node scripts/pr-policy.mjs --self-test && node scripts/check-pr-policy-workflow.mjs",
"check:env-parity": "node scripts/check-env-parity.mjs",
"sweep:branch-ledger": "node scripts/sweep-branch-ledger.mjs",
"docs:check-links": "node scripts/check-docs-links.mjs",
Expand Down
122 changes: 122 additions & 0 deletions scripts/check-pr-policy-workflow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import fs from "node:fs";

import { yamlBlock } from "./yaml-contract.mjs";

const workflowPath = ".github/workflows/pr-policy.yml";
const ciWorkflowPath = ".github/workflows/ci.yml";
const workflow = fs.readFileSync(workflowPath, "utf8");
const ciWorkflow = fs.readFileSync(ciWorkflowPath, "utf8");
const githubScriptPin = "3a2844b7e9c422d3c10d287c895573f7108da1b3";

const failures = [];

function collectCheckoutRefs(block) {
return block
.split(/\r?\n/)
.map((line) => line.match(/^\s+ref:\s*(.+?)\s*(?:#.*)?$/)?.[1]?.trim())
.filter(Boolean);
}

function assertCheckoutRefs(block, label, { allowed = [], forbidden = [] }) {
const refs = collectCheckoutRefs(block);
for (const ref of refs) {
if (forbidden.some((pattern) => pattern.test(ref))) {
failures.push(`${label} must not checkout untrusted ref ${ref}.`);
}
if (allowed.length > 0 && !allowed.includes(ref)) {
failures.push(`${label} checkout ref ${ref} is not in the allowed trusted set.`);
}
}
if (refs.length === 0) {
failures.push(`${label} is missing an actions/checkout ref declaration.`);
}
}

function assertPersistCredentialsFalse(block, label) {
if (!/persist-credentials:\s*false/.test(block)) {
failures.push(`${label} must set persist-credentials: false on checkout steps.`);
}
if (/persist-credentials:\s*true/.test(block)) {
failures.push(`${label} must not persist checkout credentials.`);
}
}

const policyJob = yamlBlock(workflow, "policy:", 2);
if (!policyJob) {
failures.push("pr-policy.yml is missing the policy job.");
} else {
const checkoutStep = yamlBlock(policyJob, "- name: Checkout trusted policy", 6);
if (!checkoutStep) {
failures.push("pr-policy.yml policy job is missing the trusted checkout step.");
} else {
assertCheckoutRefs(checkoutStep, "PR policy trusted checkout", {
allowed: ["${{ github.workflow_sha }}"],
forbidden: [/github\.event\.pull_request\.head/, /github\.base_ref/, /github\.event\.pull_request\.base\.sha/],
});
assertPersistCredentialsFalse(checkoutStep, "PR policy trusted checkout");
}

const validateStep = yamlBlock(policyJob, "- name: Validate pull request evidence", 6);
if (!validateStep) {
failures.push("pr-policy.yml policy job is missing the validation step.");
} else {
if (!validateStep.includes("GITHUB_WORKSPACE}/scripts/pr-policy.mjs")) {
failures.push("PR policy validation must import scripts/pr-policy.mjs from the trusted checkout.");
}
if (!validateStep.includes(`uses: actions/github-script@${githubScriptPin} # v9.0.0`)) {
failures.push("PR policy validation must use the pinned github-script action.");
}
}

if (
!/^permissions:\s*$/m.test(workflow) ||
!workflow.includes("contents: read") ||
!workflow.includes("pull-requests: read")
) {
failures.push("pr-policy.yml must declare read-only workflow permissions.");
}
if (/pull-requests:\s*write/.test(policyJob) || /contents:\s*write/.test(policyJob)) {
failures.push("pr-policy.yml policy job must not request write permissions.");
}
}

const syncJob = yamlBlock(ciWorkflow, "sync-pr-policy-body:", 2);
if (!syncJob) {
failures.push("ci.yml is missing the sync-pr-policy-body job.");
} else {
const trustedCheckout = yamlBlock(syncJob, "- name: Checkout trusted policy metadata", 6);
if (!trustedCheckout) {
failures.push("sync-pr-policy-body is missing the trusted policy metadata checkout step.");
} else {
assertCheckoutRefs(trustedCheckout, "sync-pr-policy-body trusted policy checkout", {
allowed: ["${{ github.event.pull_request.base.sha }}"],
forbidden: [/github\.event\.pull_request\.head/],
});
assertPersistCredentialsFalse(trustedCheckout, "sync-pr-policy-body trusted policy checkout");
}

const applyStep = yamlBlock(syncJob, "- name: Apply PR_POLICY_BODY.md to pull request description", 6);
if (!applyStep) {
failures.push("sync-pr-policy-body is missing the PR body sync step.");
} else {
if (!applyStep.includes("trusted-policy/scripts/pr-policy.mjs")) {
failures.push("sync-pr-policy-body must import pr-policy.mjs from the trusted base checkout only.");
}
if (!applyStep.includes("existingCheckedItems")) {
failures.push("sync-pr-policy-body must preserve existing governance attestations.");
}
if (/map\(\(item\) => `\s*-\s*\[x\]/i.test(applyStep)) {
failures.push("sync-pr-policy-body must not synthesize completed Clinical Governance Preflight items.");
}
}
}

if (failures.length > 0) {
console.error("PR policy workflow guard failed:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}

console.log("PR policy workflow guard passed.");
7 changes: 4 additions & 3 deletions scripts/list-database-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function validateSkillCatalog(catalog = loadSkillCatalog(), discovered =
const aliasTargets = new Map(aliases);
const discoveredByName = new Map(discovered.map((skill) => [skill.name, skill]));
const discoveredNames = discovered.map((skill) => skill.name);
const descriptions = new Map();

for (const [label, names] of [
["category", categoryNames],
Expand All @@ -95,7 +96,7 @@ export function validateSkillCatalog(catalog = loadSkillCatalog(), discovered =
else if (discoveredSkill.directory !== skill.name) {
errors.push(`Canonical skill directory mismatch: ${skill.name} is in ${discoveredSkill.directory}`);
} else {
skill.description = discoveredSkill.description;
descriptions.set(skill.name, discoveredSkill.description);
}
}

Expand Down Expand Up @@ -146,7 +147,7 @@ export function validateSkillCatalog(catalog = loadSkillCatalog(), discovered =
}
}

return { errors, canonical, aliases, discovered };
return { errors, canonical, aliases, discovered, descriptions };
}

export function summarizeSkillDescription(description) {
Expand All @@ -160,7 +161,7 @@ export function renderSkillCatalog(catalog = loadSkillCatalog(), discovered = di
const validation = validateSkillCatalog(catalog, discovered);
if (validation.errors.length) throw new Error(validation.errors.join("\n"));

const descriptions = new Map(validation.canonical.map((skill) => [skill.name, skill.description]));
const descriptions = validation.descriptions;
const lines = [`Database skills (${validation.canonical.length})`, ""];
for (const category of catalog.categories) {
lines.push(category.name);
Expand Down
17 changes: 16 additions & 1 deletion src/components/DocumentManagementActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
toolbarButton,
} from "@/components/ui-primitives";
import { Sheet } from "@/components/ui/sheet";
import { isAdministratorUser } from "@/lib/authorization";
import { useAuthSession } from "@/lib/supabase/client";
import type { ClinicalDocument } from "@/lib/types";

Expand Down Expand Up @@ -44,6 +45,7 @@ export function DocumentManagementActions({
const inputRef = useRef<HTMLInputElement>(null);
const {
status: authStatus,
session,
authorizationHeader,
registerAuthRequest,
isAuthEpochCurrent,
Expand All @@ -54,7 +56,18 @@ export function DocumentManagementActions({
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const canManage = !disabled && authStatus === "authenticated";
const canManage = !disabled && authStatus === "authenticated" && isAdministratorUser(session?.user);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function assertCanManage(): boolean {
const allowed = !disabled && authStatus === "authenticated" && isAdministratorUser(session?.user);
if (!allowed) {
setMode(null);
resetDialogState();
setError("Administrator access is required for this action.");
return false;
}
return true;
}

function resetDialogState() {
setTitle(document.title);
Expand Down Expand Up @@ -85,6 +98,7 @@ export function DocumentManagementActions({

async function submitRename(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!assertCanManage()) return;
const nextTitle = title.trim();
if (!nextTitle) {
setError("Enter a document title.");
Expand Down Expand Up @@ -119,6 +133,7 @@ export function DocumentManagementActions({

async function submitDelete(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!assertCanManage()) return;
if (deleteConfirmation !== document.title) {
setError("Type the current document title to confirm permanent deletion.");
return;
Expand Down
14 changes: 9 additions & 5 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2281,14 +2281,14 @@ export function DocumentViewer({
}, []);

async function summarize() {
if (!canSummarizeDocument) {
setSummaryError("Load a source document before summarising.");
return;
}
if (!canUsePrivateApis) {
setSummaryError("Sign in before summarising private documents.");
return;
}
if (viewerState !== "ready" || loadingSummary) {
setSummaryError("Load a source document before summarising.");
return;
}
const summaryMode = sourceSearch.trim().length === 0;
const query = summaryMode ? documentSummaryQuestion : sourceSearch.trim();
const controller = new AbortController();
Expand Down Expand Up @@ -2408,7 +2408,11 @@ export function DocumentViewer({
: documentHomeHref;
const usefulPageHref = (page: number) => documentPageHref(documentId, page);
const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis;
const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering";
const summarizeTitle = !canUsePrivateApis
? "Sign in before answering from this document"
: viewerState !== "ready" || loadingSummary
? "Load a source document before answering"
: "Answer from this document";
const pageByNumber = useMemo(() => new Map(pages.map((page) => [page.page_number, page])), [pages]);
const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk])), [chunks]);
const selectedPage = pageByNumber.get(activePage) ?? pages[0];
Expand Down
3 changes: 2 additions & 1 deletion src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "server-only";

import { z } from "zod";
import { resolvePythonBin } from "@/lib/python-bin";
import { assertExpectedSupabaseProjectConfig, checkSupabaseProjectConfig } from "@/lib/supabase/project";

const envSchema = z.object({
Expand Down Expand Up @@ -207,7 +208,7 @@ const envSchema = z.object({
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
PYTHON_BIN: z.string().default("python"),
PYTHON_BIN: z.string().default(resolvePythonBin()),
NEXT_PUBLIC_DEMO_MODE: z.enum(["true", "false"]).optional().default("false"),
});

Expand Down
17 changes: 7 additions & 10 deletions src/lib/extractors/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PdfExtractionResourceError,
type PdfExtractionBudget,
} from "@/lib/extractors/pdf-extraction-budget";
import { resolvePythonBin } from "@/lib/python-bin";

const extractedPageSchema = z.object({
pageNumber: z.number().int().positive(),
Expand Down Expand Up @@ -90,16 +91,12 @@ export async function runPythonPdfExtractor(
await writeFile(budgetPath, JSON.stringify(limits), "utf8");

return new Promise<ExtractedDocument>((resolve, reject) => {
const child = spawn(
process.env.PYTHON_BIN || "python",
[scriptPath, filePath, outputDir, outputJsonPath, budgetPath],
{
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: true,
},
);
const child = spawn(resolvePythonBin(), [scriptPath, filePath, outputDir, outputJsonPath, budgetPath], {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: true,
});

let stdout = "";
let stderr = "";
Expand Down
6 changes: 6 additions & 0 deletions src/lib/python-bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Shared Python executable resolution for extraction and worker prerequisites. */
export function resolvePythonBin(explicit = process.env.PYTHON_BIN): string {
const trimmed = explicit?.trim();
if (trimmed) return trimmed;
return process.platform === "win32" ? "python" : "python3";
Comment thread
cursor[bot] marked this conversation as resolved.
}
Loading