diff --git a/project-visibility-transition-guard/README.md b/project-visibility-transition-guard/README.md new file mode 100644 index 00000000..2af65009 --- /dev/null +++ b/project-visibility-transition-guard/README.md @@ -0,0 +1,37 @@ +# Project Visibility Transition Guard + +Self-contained User & Project Management slice for +`SCIBASE-AI/SCIBASE.AI#11`. + +The guard evaluates private, institutional-only, or invitation-only scientific +workspaces before they are made public. It checks required approvals, +collaborator profile consent, object-level document/code/data permissions, +sensitive labels, public readiness, active IRB/funder holds, external partner +access, and immutable audit evidence. + +This is intentionally separate from workspace/RBAC ledgers, privacy access +reviews, identity recovery, member lifecycle/offboarding, institutional +recertification, anonymous-review escrow, identity merge/export, data-room +consent, researcher profile sync, archive handoff, access-audit anomaly, role +delegation, invitation-domain/MFA, funding attribution, service-token governance, +deletion/erasure, break-glass access, and contribution-credit gates. Its job is +to stop unsafe public visibility transitions. + +## Run + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +## Outputs + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.webm` + +All data is synthetic. The module does not call OAuth, SAML, ORCID, storage, +profile, permission, email, audit-log, or external services. diff --git a/project-visibility-transition-guard/demo-video.js b/project-visibility-transition-guard/demo-video.js new file mode 100644 index 00000000..30d6159a --- /dev/null +++ b/project-visibility-transition-guard/demo-video.js @@ -0,0 +1,173 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const reportDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportDir, "demo.webm"); + +const chromeCandidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +].filter(Boolean); + +function findBrowser() { + const found = chromeCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm."); + } + return found; +} + +function fileUrl(filePath) { + return `file:///${filePath.replace(/\\/g, "/")}`; +} + +const html = String.raw` + +
+ +recording+ + +`; + +fs.mkdirSync(reportDir, { recursive: true }); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "visibility-transition-demo-")); +const htmlPath = path.join(tempDir, "demo.html"); +const profileDir = path.join(tempDir, "profile"); +fs.writeFileSync(htmlPath, html, "utf8"); + +const stdout = execFileSync( + findBrowser(), + [ + "--headless=new", + "--disable-gpu", + "--disable-dev-shm-usage", + "--autoplay-policy=no-user-gesture-required", + "--run-all-compositor-stages-before-draw", + "--virtual-time-budget=7500", + `--user-data-dir=${profileDir}`, + "--dump-dom", + fileUrl(htmlPath) + ], + { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 } +); + +const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/); +if (!match) { + throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`); +} + +fs.writeFileSync(outputPath, Buffer.from(match[1], "base64")); +console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); diff --git a/project-visibility-transition-guard/demo.js b/project-visibility-transition-guard/demo.js new file mode 100644 index 00000000..97c3730c --- /dev/null +++ b/project-visibility-transition-guard/demo.js @@ -0,0 +1,18 @@ +const fs = require("fs"); +const path = require("path"); +const { project } = require("./sample-data"); +const { buildReviewPacket, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const packet = buildReviewPacket(project); + +fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(packet, null, 2)}\n`, "utf8"); +fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), renderMarkdownReport(packet), "utf8"); +fs.writeFileSync(path.join(reportDir, "summary.svg"), renderSvgSummary(packet), "utf8"); + +console.log(`Generated reports for ${packet.guard}`); +console.log(`Decision: ${packet.decision}`); +console.log(`Score: ${packet.score}`); +console.log(`Findings: ${packet.findings.length}`); diff --git a/project-visibility-transition-guard/index.js b/project-visibility-transition-guard/index.js new file mode 100644 index 00000000..b5202b5b --- /dev/null +++ b/project-visibility-transition-guard/index.js @@ -0,0 +1,292 @@ +const SEVERITY_WEIGHTS = { + critical: 36, + high: 22, + medium: 10, + low: 4 +}; + +function addFinding(findings, severity, rule, message, action, refs = []) { + findings.push({ severity, rule, message, action, refs }); +} + +function collaboratorById(project) { + return new Map(project.collaborators.map((collaborator) => [collaborator.id, collaborator])); +} + +function objectById(project) { + return new Map(project.objects.map((object) => [object.id, object])); +} + +function activeHolds(project) { + return project.holds.filter((hold) => hold.status === "active"); +} + +function evaluateVisibilityTransition(project) { + const findings = []; + const collaborators = collaboratorById(project); + const objects = objectById(project); + + if (project.workspace.currentVisibility === project.workspace.requestedVisibility) { + addFinding( + findings, + "low", + "visibility-transition-noop", + `Workspace is already ${project.workspace.requestedVisibility}.`, + "Skip transition processing and emit a no-op audit receipt.", + [project.workspace.id] + ); + } + + for (const role of project.policy.requiredApproverRoles) { + const approver = project.collaborators.find((collaborator) => collaborator.role === role); + if (!approver || approver.consent !== "approved") { + addFinding( + findings, + "critical", + "required-visibility-approver-missing", + `Required ${role} approval is not complete.`, + "Block public visibility until all required governance approvers consent.", + [role] + ); + } + } + + for (const collaborator of project.collaborators) { + if (project.policy.publicProfileRequiresConsent && !collaborator.profilePublic && collaborator.consent !== "approved") { + addFinding( + findings, + collaborator.role === "external-partner" ? "high" : "medium", + "profile-exposure-consent-missing", + `${collaborator.id} has no approved consent for public profile exposure.`, + "Hide the collaborator from public profile surfaces or collect explicit consent before transition.", + [collaborator.id, collaborator.role] + ); + } + } + + for (const object of project.objects) { + const sensitiveLabels = object.labels.filter((label) => project.policy.sensitiveLabels.includes(label)); + if (sensitiveLabels.length > 0) { + addFinding( + findings, + "critical", + "sensitive-object-in-public-transition", + `${object.title} carries sensitive labels: ${sensitiveLabels.join(", ")}.`, + "Exclude or redact the object before public visibility is applied.", + [object.id, ...sensitiveLabels] + ); + } + + if (!project.policy.allowedPublicObjectKinds.includes(object.kind)) { + addFinding( + findings, + "high", + "object-kind-not-public-allowlisted", + `${object.kind} object ${object.id} is not allowlisted for public visibility.`, + "Map the object to a public-safe derivative or keep it private.", + [object.id, object.kind] + ); + } + + if (object.permission === "edit" || object.permission === "download") { + addFinding( + findings, + object.permission === "download" ? "critical" : "high", + "unsafe-public-object-permission", + `${object.id} would expose ${object.permission} permission after transition.`, + "Downgrade public permissions to read-only metadata or remove public access.", + [object.id, object.permission] + ); + } + + if (!object.publicReady) { + addFinding( + findings, + "high", + "object-not-public-ready", + `${object.title} is not marked public-ready.`, + "Require owner/steward readiness attestation before making the object public.", + [object.id] + ); + } + + if (!collaborators.has(object.ownerId)) { + addFinding( + findings, + "medium", + "object-owner-missing", + `${object.id} references missing owner ${object.ownerId}.`, + "Repair owner attribution before the public audit packet is emitted.", + [object.id, object.ownerId] + ); + } + } + + for (const hold of activeHolds(project)) { + for (const objectId of hold.objectIds) { + const object = objects.get(objectId); + addFinding( + findings, + "critical", + "active-hold-blocks-public-transition", + `${hold.kind} hold ${hold.id} blocks public exposure of ${object ? object.title : objectId}.`, + "Block the visibility transition until the hold expires or a documented waiver is attached.", + [hold.id, objectId, hold.expiresAt] + ); + } + } + + for (const invite of project.externalAccess) { + if (!collaborators.has(invite.collaboratorId)) { + addFinding( + findings, + "high", + "external-access-principal-unknown", + `External access ${invite.id} references unknown principal ${invite.collaboratorId}.`, + "Revoke or identify unknown external access before public transition.", + [invite.id, invite.collaboratorId] + ); + } + if (invite.access === "download" && !invite.allowsRedistribution) { + addFinding( + findings, + "high", + "external-download-without-redistribution-rights", + `External access ${invite.id} allows downloads without redistribution rights.`, + "Downgrade or revoke external download grants before public visibility changes.", + [invite.id] + ); + } + } + + if (project.auditEvents.length < project.policy.minimumAuditEvents) { + addFinding( + findings, + "medium", + "visibility-audit-evidence-incomplete", + `Only ${project.auditEvents.length} audit events are present for the transition.`, + "Record requester, approver, object-review, and final decision events before applying visibility.", + [project.workspace.id] + ); + } + + const severitySummary = findings.reduce( + (summary, finding) => { + summary[finding.severity] += 1; + return summary; + }, + { critical: 0, high: 0, medium: 0, low: 0 } + ); + const score = Math.max(0, 100 - findings.reduce((sum, finding) => sum + SEVERITY_WEIGHTS[finding.severity], 0)); + + return { findings, severitySummary, score }; +} + +function decisionFromEvaluation(evaluation) { + if (evaluation.severitySummary.critical > 0) { + return "block-public-visibility-transition"; + } + if (evaluation.severitySummary.high > 0 || evaluation.score < 75) { + return "hold-transition-for-governance-review"; + } + if (evaluation.score < 90) { + return "manual-review-before-publication"; + } + return "visibility-transition-ready"; +} + +function buildTransitionActions(findings) { + return findings.map((finding) => ({ + priority: finding.severity === "critical" || finding.severity === "high" ? "blocking" : "review", + rule: finding.rule, + action: finding.action, + refs: finding.refs + })); +} + +function buildReviewPacket(project) { + const evaluation = evaluateVisibilityTransition(project); + return { + guard: "project-visibility-transition-guard", + issue: "SCIBASE-AI/SCIBASE.AI#11", + workspaceId: project.workspace.id, + title: project.workspace.title, + currentVisibility: project.workspace.currentVisibility, + requestedVisibility: project.workspace.requestedVisibility, + asOfDate: project.asOfDate, + decision: decisionFromEvaluation(evaluation), + score: evaluation.score, + severitySummary: evaluation.severitySummary, + findings: evaluation.findings, + transitionActions: buildTransitionActions(evaluation.findings), + safety: [ + "Synthetic project, collaborator, object, hold, access, and audit data only", + "No OAuth, SAML, ORCID, storage, profile, permission, email, or audit-log network calls", + "No private project data, credentials, human-subject records, live users, or access-control mutations" + ] + }; +} + +function renderMarkdownReport(packet) { + const lines = [ + "# Project Visibility Transition Guard", + "", + `Workspace: ${packet.title}`, + `Issue: ${packet.issue}`, + `Transition: ${packet.currentVisibility} -> ${packet.requestedVisibility}`, + `Decision: ${packet.decision}`, + `Score: ${packet.score}`, + "", + "## Severity Summary", + "", + "| Severity | Count |", + "| --- | ---: |" + ]; + + for (const severity of ["critical", "high", "medium", "low"]) { + lines.push(`| ${severity} | ${packet.severitySummary[severity]} |`); + } + + lines.push("", "## Findings", ""); + for (const finding of packet.findings) { + lines.push(`- **${finding.severity} / ${finding.rule}**: ${finding.message}`); + lines.push(` - Action: ${finding.action}`); + lines.push(` - Refs: ${finding.refs.join(", ") || "none"}`); + } + + lines.push("", "## Safety", ""); + for (const item of packet.safety) { + lines.push(`- ${item}`); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(packet) { + const barWidth = Math.max(44, Math.min(760, packet.score * 7.6)); + return ` +`; +} + +module.exports = { + buildReviewPacket, + decisionFromEvaluation, + evaluateVisibilityTransition, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/project-visibility-transition-guard/package.json b/project-visibility-transition-guard/package.json new file mode 100644 index 00000000..db67484f --- /dev/null +++ b/project-visibility-transition-guard/package.json @@ -0,0 +1,14 @@ +{ + "name": "project-visibility-transition-guard", + "version": "1.0.0", + "description": "Deterministic governance guard for scientific project visibility transitions.", + "main": "index.js", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check demo-video.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node demo-video.js" + } +} diff --git a/project-visibility-transition-guard/reports/demo.webm b/project-visibility-transition-guard/reports/demo.webm new file mode 100644 index 00000000..9cf0df57 Binary files /dev/null and b/project-visibility-transition-guard/reports/demo.webm differ diff --git a/project-visibility-transition-guard/reports/reviewer-packet.md b/project-visibility-transition-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..fef0dae2 --- /dev/null +++ b/project-visibility-transition-guard/reports/reviewer-packet.md @@ -0,0 +1,73 @@ +# Project Visibility Transition Guard + +Workspace: Synthetic Proteomics Collaboration +Issue: SCIBASE-AI/SCIBASE.AI#11 +Transition: institutional-only -> public +Decision: block-public-visibility-transition +Score: 0 + +## Severity Summary + +| Severity | Count | +| --- | ---: | +| critical | 6 | +| high | 8 | +| medium | 2 | +| low | 0 | + +## Findings + +- **critical / required-visibility-approver-missing**: Required data-steward approval is not complete. + - Action: Block public visibility until all required governance approvers consent. + - Refs: data-steward +- **medium / profile-exposure-consent-missing**: user-steward has no approved consent for public profile exposure. + - Action: Hide the collaborator from public profile surfaces or collect explicit consent before transition. + - Refs: user-steward, data-steward +- **high / profile-exposure-consent-missing**: user-partner has no approved consent for public profile exposure. + - Action: Hide the collaborator from public profile surfaces or collect explicit consent before transition. + - Refs: user-partner, external-partner +- **critical / sensitive-object-in-public-transition**: Raw participant proteomics table carries sensitive labels: restricted-data, human-derived. + - Action: Exclude or redact the object before public visibility is applied. + - Refs: obj-dataset-raw, restricted-data, human-derived +- **high / object-kind-not-public-allowlisted**: dataset object obj-dataset-raw is not allowlisted for public visibility. + - Action: Map the object to a public-safe derivative or keep it private. + - Refs: obj-dataset-raw, dataset +- **critical / unsafe-public-object-permission**: obj-dataset-raw would expose download permission after transition. + - Action: Downgrade public permissions to read-only metadata or remove public access. + - Refs: obj-dataset-raw, download +- **high / object-not-public-ready**: Raw participant proteomics table is not marked public-ready. + - Action: Require owner/steward readiness attestation before making the object public. + - Refs: obj-dataset-raw +- **high / unsafe-public-object-permission**: obj-code would expose edit permission after transition. + - Action: Downgrade public permissions to read-only metadata or remove public access. + - Refs: obj-code, edit +- **critical / sensitive-object-in-public-transition**: Anonymous reviewer discussion carries sensitive labels: anonymous-review. + - Action: Exclude or redact the object before public visibility is applied. + - Refs: obj-review, anonymous-review +- **high / object-kind-not-public-allowlisted**: comment-thread object obj-review is not allowlisted for public visibility. + - Action: Map the object to a public-safe derivative or keep it private. + - Refs: obj-review, comment-thread +- **high / object-not-public-ready**: Anonymous reviewer discussion is not marked public-ready. + - Action: Require owner/steward readiness attestation before making the object public. + - Refs: obj-review +- **critical / active-hold-blocks-public-transition**: IRB hold hold-irb blocks public exposure of Raw participant proteomics table. + - Action: Block the visibility transition until the hold expires or a documented waiver is attached. + - Refs: hold-irb, obj-dataset-raw, 2026-08-01 +- **critical / active-hold-blocks-public-transition**: funder-embargo hold hold-funder blocks public exposure of Draft manuscript. + - Action: Block the visibility transition until the hold expires or a documented waiver is attached. + - Refs: hold-funder, obj-manuscript, 2026-06-15 +- **high / external-access-principal-unknown**: External access invite-2 references unknown principal unknown-consultant. + - Action: Revoke or identify unknown external access before public transition. + - Refs: invite-2, unknown-consultant +- **high / external-download-without-redistribution-rights**: External access invite-2 allows downloads without redistribution rights. + - Action: Downgrade or revoke external download grants before public visibility changes. + - Refs: invite-2 +- **medium / visibility-audit-evidence-incomplete**: Only 2 audit events are present for the transition. + - Action: Record requester, approver, object-review, and final decision events before applying visibility. + - Refs: proj-visible-221 + +## Safety + +- Synthetic project, collaborator, object, hold, access, and audit data only +- No OAuth, SAML, ORCID, storage, profile, permission, email, or audit-log network calls +- No private project data, credentials, human-subject records, live users, or access-control mutations diff --git a/project-visibility-transition-guard/reports/summary.json b/project-visibility-transition-guard/reports/summary.json new file mode 100644 index 00000000..769a7039 --- /dev/null +++ b/project-visibility-transition-guard/reports/summary.json @@ -0,0 +1,326 @@ +{ + "guard": "project-visibility-transition-guard", + "issue": "SCIBASE-AI/SCIBASE.AI#11", + "workspaceId": "proj-visible-221", + "title": "Synthetic Proteomics Collaboration", + "currentVisibility": "institutional-only", + "requestedVisibility": "public", + "asOfDate": "2026-05-22", + "decision": "block-public-visibility-transition", + "score": 0, + "severitySummary": { + "critical": 6, + "high": 8, + "medium": 2, + "low": 0 + }, + "findings": [ + { + "severity": "critical", + "rule": "required-visibility-approver-missing", + "message": "Required data-steward approval is not complete.", + "action": "Block public visibility until all required governance approvers consent.", + "refs": [ + "data-steward" + ] + }, + { + "severity": "medium", + "rule": "profile-exposure-consent-missing", + "message": "user-steward has no approved consent for public profile exposure.", + "action": "Hide the collaborator from public profile surfaces or collect explicit consent before transition.", + "refs": [ + "user-steward", + "data-steward" + ] + }, + { + "severity": "high", + "rule": "profile-exposure-consent-missing", + "message": "user-partner has no approved consent for public profile exposure.", + "action": "Hide the collaborator from public profile surfaces or collect explicit consent before transition.", + "refs": [ + "user-partner", + "external-partner" + ] + }, + { + "severity": "critical", + "rule": "sensitive-object-in-public-transition", + "message": "Raw participant proteomics table carries sensitive labels: restricted-data, human-derived.", + "action": "Exclude or redact the object before public visibility is applied.", + "refs": [ + "obj-dataset-raw", + "restricted-data", + "human-derived" + ] + }, + { + "severity": "high", + "rule": "object-kind-not-public-allowlisted", + "message": "dataset object obj-dataset-raw is not allowlisted for public visibility.", + "action": "Map the object to a public-safe derivative or keep it private.", + "refs": [ + "obj-dataset-raw", + "dataset" + ] + }, + { + "severity": "critical", + "rule": "unsafe-public-object-permission", + "message": "obj-dataset-raw would expose download permission after transition.", + "action": "Downgrade public permissions to read-only metadata or remove public access.", + "refs": [ + "obj-dataset-raw", + "download" + ] + }, + { + "severity": "high", + "rule": "object-not-public-ready", + "message": "Raw participant proteomics table is not marked public-ready.", + "action": "Require owner/steward readiness attestation before making the object public.", + "refs": [ + "obj-dataset-raw" + ] + }, + { + "severity": "high", + "rule": "unsafe-public-object-permission", + "message": "obj-code would expose edit permission after transition.", + "action": "Downgrade public permissions to read-only metadata or remove public access.", + "refs": [ + "obj-code", + "edit" + ] + }, + { + "severity": "critical", + "rule": "sensitive-object-in-public-transition", + "message": "Anonymous reviewer discussion carries sensitive labels: anonymous-review.", + "action": "Exclude or redact the object before public visibility is applied.", + "refs": [ + "obj-review", + "anonymous-review" + ] + }, + { + "severity": "high", + "rule": "object-kind-not-public-allowlisted", + "message": "comment-thread object obj-review is not allowlisted for public visibility.", + "action": "Map the object to a public-safe derivative or keep it private.", + "refs": [ + "obj-review", + "comment-thread" + ] + }, + { + "severity": "high", + "rule": "object-not-public-ready", + "message": "Anonymous reviewer discussion is not marked public-ready.", + "action": "Require owner/steward readiness attestation before making the object public.", + "refs": [ + "obj-review" + ] + }, + { + "severity": "critical", + "rule": "active-hold-blocks-public-transition", + "message": "IRB hold hold-irb blocks public exposure of Raw participant proteomics table.", + "action": "Block the visibility transition until the hold expires or a documented waiver is attached.", + "refs": [ + "hold-irb", + "obj-dataset-raw", + "2026-08-01" + ] + }, + { + "severity": "critical", + "rule": "active-hold-blocks-public-transition", + "message": "funder-embargo hold hold-funder blocks public exposure of Draft manuscript.", + "action": "Block the visibility transition until the hold expires or a documented waiver is attached.", + "refs": [ + "hold-funder", + "obj-manuscript", + "2026-06-15" + ] + }, + { + "severity": "high", + "rule": "external-access-principal-unknown", + "message": "External access invite-2 references unknown principal unknown-consultant.", + "action": "Revoke or identify unknown external access before public transition.", + "refs": [ + "invite-2", + "unknown-consultant" + ] + }, + { + "severity": "high", + "rule": "external-download-without-redistribution-rights", + "message": "External access invite-2 allows downloads without redistribution rights.", + "action": "Downgrade or revoke external download grants before public visibility changes.", + "refs": [ + "invite-2" + ] + }, + { + "severity": "medium", + "rule": "visibility-audit-evidence-incomplete", + "message": "Only 2 audit events are present for the transition.", + "action": "Record requester, approver, object-review, and final decision events before applying visibility.", + "refs": [ + "proj-visible-221" + ] + } + ], + "transitionActions": [ + { + "priority": "blocking", + "rule": "required-visibility-approver-missing", + "action": "Block public visibility until all required governance approvers consent.", + "refs": [ + "data-steward" + ] + }, + { + "priority": "review", + "rule": "profile-exposure-consent-missing", + "action": "Hide the collaborator from public profile surfaces or collect explicit consent before transition.", + "refs": [ + "user-steward", + "data-steward" + ] + }, + { + "priority": "blocking", + "rule": "profile-exposure-consent-missing", + "action": "Hide the collaborator from public profile surfaces or collect explicit consent before transition.", + "refs": [ + "user-partner", + "external-partner" + ] + }, + { + "priority": "blocking", + "rule": "sensitive-object-in-public-transition", + "action": "Exclude or redact the object before public visibility is applied.", + "refs": [ + "obj-dataset-raw", + "restricted-data", + "human-derived" + ] + }, + { + "priority": "blocking", + "rule": "object-kind-not-public-allowlisted", + "action": "Map the object to a public-safe derivative or keep it private.", + "refs": [ + "obj-dataset-raw", + "dataset" + ] + }, + { + "priority": "blocking", + "rule": "unsafe-public-object-permission", + "action": "Downgrade public permissions to read-only metadata or remove public access.", + "refs": [ + "obj-dataset-raw", + "download" + ] + }, + { + "priority": "blocking", + "rule": "object-not-public-ready", + "action": "Require owner/steward readiness attestation before making the object public.", + "refs": [ + "obj-dataset-raw" + ] + }, + { + "priority": "blocking", + "rule": "unsafe-public-object-permission", + "action": "Downgrade public permissions to read-only metadata or remove public access.", + "refs": [ + "obj-code", + "edit" + ] + }, + { + "priority": "blocking", + "rule": "sensitive-object-in-public-transition", + "action": "Exclude or redact the object before public visibility is applied.", + "refs": [ + "obj-review", + "anonymous-review" + ] + }, + { + "priority": "blocking", + "rule": "object-kind-not-public-allowlisted", + "action": "Map the object to a public-safe derivative or keep it private.", + "refs": [ + "obj-review", + "comment-thread" + ] + }, + { + "priority": "blocking", + "rule": "object-not-public-ready", + "action": "Require owner/steward readiness attestation before making the object public.", + "refs": [ + "obj-review" + ] + }, + { + "priority": "blocking", + "rule": "active-hold-blocks-public-transition", + "action": "Block the visibility transition until the hold expires or a documented waiver is attached.", + "refs": [ + "hold-irb", + "obj-dataset-raw", + "2026-08-01" + ] + }, + { + "priority": "blocking", + "rule": "active-hold-blocks-public-transition", + "action": "Block the visibility transition until the hold expires or a documented waiver is attached.", + "refs": [ + "hold-funder", + "obj-manuscript", + "2026-06-15" + ] + }, + { + "priority": "blocking", + "rule": "external-access-principal-unknown", + "action": "Revoke or identify unknown external access before public transition.", + "refs": [ + "invite-2", + "unknown-consultant" + ] + }, + { + "priority": "blocking", + "rule": "external-download-without-redistribution-rights", + "action": "Downgrade or revoke external download grants before public visibility changes.", + "refs": [ + "invite-2" + ] + }, + { + "priority": "review", + "rule": "visibility-audit-evidence-incomplete", + "action": "Record requester, approver, object-review, and final decision events before applying visibility.", + "refs": [ + "proj-visible-221" + ] + } + ], + "safety": [ + "Synthetic project, collaborator, object, hold, access, and audit data only", + "No OAuth, SAML, ORCID, storage, profile, permission, email, or audit-log network calls", + "No private project data, credentials, human-subject records, live users, or access-control mutations" + ] +} diff --git a/project-visibility-transition-guard/reports/summary.svg b/project-visibility-transition-guard/reports/summary.svg new file mode 100644 index 00000000..337d9a45 --- /dev/null +++ b/project-visibility-transition-guard/reports/summary.svg @@ -0,0 +1,16 @@ + diff --git a/project-visibility-transition-guard/requirements-map.md b/project-visibility-transition-guard/requirements-map.md new file mode 100644 index 00000000..1942dde8 --- /dev/null +++ b/project-visibility-transition-guard/requirements-map.md @@ -0,0 +1,18 @@ +# Requirements Map + +Issue: `SCIBASE-AI/SCIBASE.AI#11` + +| Issue requirement | Implementation | +| --- | --- | +| User and project management | Models a scientific workspace, collaborators, objects, external access, holds, and audit evidence for a visibility transition. | +| Visibility settings | Evaluates `institutional-only -> public` transitions before the workspace is exposed. | +| Role-based governance | Requires owner, data-steward, and institution-admin approvals before public release. | +| Fine-grained object-level control | Blocks unsafe public object permissions, restricted datasets, anonymous-review comments, and non-allowlisted object kinds. | +| Audit log | Requires minimum transition audit evidence and emits deterministic review actions. | +| Safe local validation | Includes dependency-free tests and demo generation from synthetic project metadata only. | + +## Non-goals + +- No live OAuth, SAML, ORCID, profile, storage, permission, or audit-log calls. +- No private project data, credentials, real users, or access-control mutations. +- No replacement for token, deletion, break-glass, profile-sync, funding, or contribution-credit workflows. diff --git a/project-visibility-transition-guard/sample-data.js b/project-visibility-transition-guard/sample-data.js new file mode 100644 index 00000000..dcac4810 --- /dev/null +++ b/project-visibility-transition-guard/sample-data.js @@ -0,0 +1,76 @@ +const project = { + asOfDate: "2026-05-22", + workspace: { + id: "proj-visible-221", + title: "Synthetic Proteomics Collaboration", + currentVisibility: "institutional-only", + requestedVisibility: "public", + requestedBy: "user-owner", + requestedAt: "2026-05-22T17:15:00Z" + }, + policy: { + allowedPublicObjectKinds: ["manuscript", "readme", "citation", "public-code"], + requiredApproverRoles: ["owner", "data-steward", "institution-admin"], + sensitiveLabels: ["restricted-data", "human-derived", "partner-confidential", "anonymous-review"], + publicProfileRequiresConsent: true, + minimumAuditEvents: 4 + }, + collaborators: [ + { id: "user-owner", role: "owner", consent: "approved", consentAt: "2026-05-21", profilePublic: true }, + { id: "user-steward", role: "data-steward", consent: "pending", consentAt: null, profilePublic: false }, + { id: "user-admin", role: "institution-admin", consent: "approved", consentAt: "2026-05-20", profilePublic: true }, + { id: "user-partner", role: "external-partner", consent: "missing", consentAt: null, profilePublic: false } + ], + objects: [ + { + id: "obj-manuscript", + kind: "manuscript", + title: "Draft manuscript", + permission: "read", + labels: [], + publicReady: true, + ownerId: "user-owner" + }, + { + id: "obj-dataset-raw", + kind: "dataset", + title: "Raw participant proteomics table", + permission: "download", + labels: ["restricted-data", "human-derived"], + publicReady: false, + ownerId: "user-steward" + }, + { + id: "obj-code", + kind: "public-code", + title: "Analysis notebook", + permission: "edit", + labels: [], + publicReady: true, + ownerId: "user-owner" + }, + { + id: "obj-review", + kind: "comment-thread", + title: "Anonymous reviewer discussion", + permission: "read", + labels: ["anonymous-review"], + publicReady: false, + ownerId: "user-partner" + } + ], + holds: [ + { id: "hold-irb", kind: "IRB", status: "active", objectIds: ["obj-dataset-raw"], expiresAt: "2026-08-01" }, + { id: "hold-funder", kind: "funder-embargo", status: "active", objectIds: ["obj-manuscript"], expiresAt: "2026-06-15" } + ], + externalAccess: [ + { id: "invite-1", collaboratorId: "user-partner", access: "read", expiresAt: "2026-06-01", allowsRedistribution: false }, + { id: "invite-2", collaboratorId: "unknown-consultant", access: "download", expiresAt: "2026-06-15", allowsRedistribution: false } + ], + auditEvents: [ + { id: "audit-1", actorId: "user-owner", action: "visibility-requested", at: "2026-05-22T17:15:00Z" }, + { id: "audit-2", actorId: "user-admin", action: "institution-approved", at: "2026-05-22T17:18:00Z" } + ] +}; + +module.exports = { project }; diff --git a/project-visibility-transition-guard/test.js b/project-visibility-transition-guard/test.js new file mode 100644 index 00000000..17797893 --- /dev/null +++ b/project-visibility-transition-guard/test.js @@ -0,0 +1,94 @@ +const assert = require("assert"); +const { project } = require("./sample-data"); +const { + buildReviewPacket, + evaluateVisibilityTransition, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); + +const evaluation = evaluateVisibilityTransition(project); +const packet = buildReviewPacket(project); + +assert.strictEqual(packet.guard, "project-visibility-transition-guard"); +assert.strictEqual(packet.issue, "SCIBASE-AI/SCIBASE.AI#11"); +assert.strictEqual(packet.decision, "block-public-visibility-transition"); + +assert.ok( + evaluation.findings.some((finding) => finding.rule === "required-visibility-approver-missing"), + "expected missing required approver finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "sensitive-object-in-public-transition"), + "expected sensitive object finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "unsafe-public-object-permission"), + "expected unsafe permission finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "active-hold-blocks-public-transition"), + "expected active hold finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "external-access-principal-unknown"), + "expected unknown external access finding" +); + +const cleanProject = JSON.parse(JSON.stringify(project)); +cleanProject.collaborators.forEach((collaborator) => { + collaborator.consent = "approved"; + collaborator.consentAt = "2026-05-22"; + collaborator.profilePublic = true; +}); +cleanProject.objects = [ + { + id: "obj-manuscript", + kind: "manuscript", + title: "Accepted manuscript", + permission: "read", + labels: [], + publicReady: true, + ownerId: "user-owner" + }, + { + id: "obj-readme", + kind: "readme", + title: "Public project summary", + permission: "read", + labels: [], + publicReady: true, + ownerId: "user-owner" + }, + { + id: "obj-code", + kind: "public-code", + title: "Read-only analysis notebook", + permission: "read", + labels: [], + publicReady: true, + ownerId: "user-owner" + } +]; +cleanProject.holds = []; +cleanProject.externalAccess = []; +cleanProject.auditEvents = [ + { id: "audit-1", actorId: "user-owner", action: "visibility-requested", at: "2026-05-22T17:15:00Z" }, + { id: "audit-2", actorId: "user-steward", action: "object-reviewed", at: "2026-05-22T17:16:00Z" }, + { id: "audit-3", actorId: "user-admin", action: "institution-approved", at: "2026-05-22T17:18:00Z" }, + { id: "audit-4", actorId: "user-owner", action: "public-release-approved", at: "2026-05-22T17:20:00Z" } +]; + +const cleanPacket = buildReviewPacket(cleanProject); +assert.strictEqual(cleanPacket.decision, "visibility-transition-ready"); +assert.strictEqual(cleanPacket.findings.length, 0); + +const markdown = renderMarkdownReport(packet); +assert.ok(markdown.includes("## Findings")); +assert.ok(markdown.includes("sensitive-object-in-public-transition")); + +const svg = renderSvgSummary(packet); +assert.ok(svg.includes("