From d79e6b5f7d2fb89523126550b699aaa6050137a9 Mon Sep 17 00:00:00 2001 From: taherd <183945978+taherdhanera@users.noreply.github.com> Date: Fri, 22 May 2026 23:17:38 +0530 Subject: [PATCH] Add project visibility transition guard --- project-visibility-transition-guard/README.md | 37 ++ .../demo-video.js | 173 ++++++++++ project-visibility-transition-guard/demo.js | 18 + project-visibility-transition-guard/index.js | 292 ++++++++++++++++ .../package.json | 14 + .../reports/demo.webm | Bin 0 -> 12099 bytes .../reports/reviewer-packet.md | 73 ++++ .../reports/summary.json | 326 ++++++++++++++++++ .../reports/summary.svg | 16 + .../requirements-map.md | 18 + .../sample-data.js | 76 ++++ project-visibility-transition-guard/test.js | 94 +++++ 12 files changed, 1137 insertions(+) create mode 100644 project-visibility-transition-guard/README.md create mode 100644 project-visibility-transition-guard/demo-video.js create mode 100644 project-visibility-transition-guard/demo.js create mode 100644 project-visibility-transition-guard/index.js create mode 100644 project-visibility-transition-guard/package.json create mode 100644 project-visibility-transition-guard/reports/demo.webm create mode 100644 project-visibility-transition-guard/reports/reviewer-packet.md create mode 100644 project-visibility-transition-guard/reports/summary.json create mode 100644 project-visibility-transition-guard/reports/summary.svg create mode 100644 project-visibility-transition-guard/requirements-map.md create mode 100644 project-visibility-transition-guard/sample-data.js create mode 100644 project-visibility-transition-guard/test.js 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` + + + + Project visibility transition guard demo + + + + +
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 ` + + Project Visibility Transition Guard + ${packet.currentVisibility} to ${packet.requestedVisibility} workspace review + + ${packet.decision} + Critical ${packet.severitySummary.critical} | High ${packet.severitySummary.high} | Findings ${packet.findings.length} + + Transition readiness score + + + ${packet.score}/100 + + Block unsafe public releases + Checks object permissions, sensitive labels, consent, holds, external access, and audit evidence. + +`; +} + +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 0000000000000000000000000000000000000000..9cf0df5712ee79f36c0ebd8a3729bab10ddc9189 GIT binary patch literal 12099 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 b`0cm`@Q+&d*pBE%2;h6zUnTRGkpBMx+-CLT literal 0 HcmV?d00001 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 @@ + + + Project Visibility Transition Guard + institutional-only to public workspace review + + block-public-visibility-transition + Critical 6 | High 8 | Findings 16 + + Transition readiness score + + + 0/100 + + Block unsafe public releases + Checks object permissions, sensitive labels, consent, holds, external access, and audit evidence. + 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("