diff --git a/collaborative-chat-mention-scope-guard/README.md b/collaborative-chat-mention-scope-guard/README.md new file mode 100644 index 00000000..47c19b80 --- /dev/null +++ b/collaborative-chat-mention-scope-guard/README.md @@ -0,0 +1,34 @@ +# Collaborative Chat Mention Scope Guard + +This module is a focused real-time collaborative editor slice for issue #12. It +checks whether document chat and sidebar `@mentions` are safe before alerts or +in-app notifications are created. + +The guard uses synthetic data only and has no external service dependencies. + +## Checks + +- Mentioned collaborators who are not members of the target section. +- Blinded reviewer identities mentioned in author-visible threads. +- External collaborators mentioned in restricted sections. +- Notebook-cell mentions that target users without cell access. +- Mentions inside locked sections that require owner approval. +- Redaction actions for private section titles before fanout. + +## Local Verification + +```bash +node collaborative-chat-mention-scope-guard/test.js +node collaborative-chat-mention-scope-guard/demo.js +``` + +Demo artifacts are written to +`collaborative-chat-mention-scope-guard/reports/`. + +## Issue #12 Mapping + +This maps to the editor's document chat, section collaboration, notebook-cell +annotation, user presence, and controlled-section workflows. It is separate from +discussion sidebar auditing, notification visibility, private-comment export, +presence privacy/liveness, suggestion provenance, clipboard import, local cache, +find/replace, data availability, and section-lock arbitration slices. diff --git a/collaborative-chat-mention-scope-guard/demo.js b/collaborative-chat-mention-scope-guard/demo.js new file mode 100644 index 00000000..023644fe --- /dev/null +++ b/collaborative-chat-mention-scope-guard/demo.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateMentionFanout, toMarkdown, toSvg } = require("./mentionScopeGuard"); +const sampleWorkspace = require("./sampleWorkspace"); + +const result = evaluateMentionFanout(sampleWorkspace); +const reportDir = path.join(__dirname, "reports"); + +fs.mkdirSync(reportDir, { recursive: true }); +fs.writeFileSync(path.join(reportDir, "mention-scope-packet.json"), `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(path.join(reportDir, "mention-scope-report.md"), toMarkdown(result)); +fs.writeFileSync(path.join(reportDir, "summary.svg"), toSvg(result)); + +console.log(`decision=${result.decision}`); +console.log(`findings=${result.findings.length}`); +console.log(`reports=${reportDir}`); diff --git a/collaborative-chat-mention-scope-guard/mentionScopeGuard.js b/collaborative-chat-mention-scope-guard/mentionScopeGuard.js new file mode 100644 index 00000000..bf0cf6ad --- /dev/null +++ b/collaborative-chat-mention-scope-guard/mentionScopeGuard.js @@ -0,0 +1,160 @@ +function addFinding(findings, severity, id, message, action, evidence = {}) { + findings.push({ severity, id, message, action, evidence }); +} + +function evaluateMentionFanout(workspace) { + const findings = []; + const usersById = new Map((workspace.users || []).map((user) => [user.id, user])); + const sectionsById = new Map((workspace.sections || []).map((section) => [section.id, section])); + const cellsById = new Map((workspace.notebookCells || []).map((cell) => [cell.id, cell])); + + for (const thread of workspace.chatThreads || []) { + const section = sectionsById.get(thread.sectionId); + + if (!section) { + addFinding( + findings, + "block", + `unknown-section:${thread.id}`, + `Thread ${thread.id} targets an unknown document section.`, + "Resolve the thread section before creating mention fanout.", + { threadId: thread.id, sectionId: thread.sectionId } + ); + continue; + } + + for (const mentionId of thread.mentions || []) { + const user = usersById.get(mentionId); + if (!user) { + addFinding( + findings, + "block", + `unknown-mention:${thread.id}:${mentionId}`, + `Thread ${thread.id} mentions an unknown collaborator ${mentionId}.`, + "Drop or resolve unknown collaborators before fanout.", + { threadId: thread.id, mentionId } + ); + continue; + } + + if (!section.members.includes(mentionId)) { + addFinding( + findings, + "hold", + `section-scope:${thread.id}:${mentionId}`, + `${user.displayName} is mentioned outside their allowed section scope.`, + "Invite the collaborator to the section or remove the mention.", + { threadId: thread.id, sectionId: section.id, mentionId } + ); + } + + if (user.role === "blinded-reviewer" && thread.visibility === "author-visible") { + addFinding( + findings, + "block", + `blinded-reviewer-leak:${thread.id}:${mentionId}`, + `Author-visible thread ${thread.id} would reveal blinded reviewer ${user.displayName}.`, + "Redact the reviewer mention or move the message to a reviewer-only thread.", + { threadId: thread.id, mentionId } + ); + } + + if (user.external && section.restricted) { + addFinding( + findings, + "block", + `external-restricted-section:${thread.id}:${mentionId}`, + `External collaborator ${user.displayName} is mentioned in restricted section ${section.id}.`, + "Remove the mention or approve restricted-section access before fanout.", + { threadId: thread.id, sectionId: section.id, mentionId } + ); + } + + if (thread.notebookCellId) { + const cell = cellsById.get(thread.notebookCellId); + if (!cell || !cell.allowedUsers.includes(mentionId)) { + addFinding( + findings, + "hold", + `notebook-cell-scope:${thread.id}:${mentionId}`, + `${user.displayName} is mentioned on notebook cell ${thread.notebookCellId} without cell access.`, + "Grant cell access or move the mention to a document-level thread.", + { threadId: thread.id, notebookCellId: thread.notebookCellId, mentionId } + ); + } + } + } + + if (section.locked && thread.mentions.length > 0 && !thread.sectionOwnerApproved) { + addFinding( + findings, + "hold", + `locked-section-mention:${thread.id}`, + `Thread ${thread.id} mentions collaborators inside locked section ${section.id} without owner approval.`, + "Ask the section owner to approve the mention fanout or defer it until unlock.", + { threadId: thread.id, sectionId: section.id } + ); + } + + if (section.privateTitle && thread.fanoutPreview && thread.fanoutPreview.includes(section.title)) { + addFinding( + findings, + "hold", + `private-title-preview:${thread.id}`, + `Thread ${thread.id} fanout preview includes a private section title.`, + "Redact the section title in the mention preview before alerting collaborators.", + { threadId: thread.id, sectionId: section.id } + ); + } + } + + const blockers = findings.filter((finding) => finding.severity === "block").length; + const holds = findings.filter((finding) => finding.severity === "hold").length; + + return { + workspaceId: workspace.id, + decision: blockers > 0 ? "block-mention-fanout" : holds > 0 ? "hold-for-thread-owner" : "ready-for-mention-fanout", + summary: { + threads: (workspace.chatThreads || []).length, + blockers, + holds, + findings: findings.length + }, + findings + }; +} + +function toMarkdown(result) { + const rows = result.findings.map((finding) => `| ${finding.severity} | ${finding.message} | ${finding.action} |`); + return [ + "# Chat Mention Scope Guard Report", + "", + `Workspace: ${result.workspaceId}`, + `Decision: ${result.decision}`, + "", + "| Severity | Finding | Action |", + "| --- | --- | --- |", + ...(rows.length ? rows : ["| ok | No mention-scope issues found. | Mention fanout may continue. |"]), + "" + ].join("\n"); +} + +function toSvg(result) { + const color = result.decision === "ready-for-mention-fanout" ? "#0f7b45" : result.decision === "hold-for-thread-owner" ? "#9a6700" : "#b42318"; + return [ + ``, + ``, + ``, + `Chat Mention Scope Guard`, + `Decision: ${result.decision}`, + `Blockers: ${result.summary.blockers} | Holds: ${result.summary.holds} | Threads: ${result.summary.threads}`, + `Workspace ${result.workspaceId}`, + `` + ].join("\n"); +} + +module.exports = { + evaluateMentionFanout, + toMarkdown, + toSvg +}; diff --git a/collaborative-chat-mention-scope-guard/reports/demo.webm b/collaborative-chat-mention-scope-guard/reports/demo.webm new file mode 100644 index 00000000..05a85d93 Binary files /dev/null and b/collaborative-chat-mention-scope-guard/reports/demo.webm differ diff --git a/collaborative-chat-mention-scope-guard/reports/mention-scope-packet.json b/collaborative-chat-mention-scope-guard/reports/mention-scope-packet.json new file mode 100644 index 00000000..9c04a957 --- /dev/null +++ b/collaborative-chat-mention-scope-guard/reports/mention-scope-packet.json @@ -0,0 +1,97 @@ +{ + "workspaceId": "editor-room-chat-118", + "decision": "block-mention-fanout", + "summary": { + "threads": 2, + "blockers": 2, + "holds": 6, + "findings": 8 + }, + "findings": [ + { + "severity": "hold", + "id": "section-scope:thread-1:u-reviewer", + "message": "Reviewer B is mentioned outside their allowed section scope.", + "action": "Invite the collaborator to the section or remove the mention.", + "evidence": { + "threadId": "thread-1", + "sectionId": "methods", + "mentionId": "u-reviewer" + } + }, + { + "severity": "block", + "id": "blinded-reviewer-leak:thread-1:u-reviewer", + "message": "Author-visible thread thread-1 would reveal blinded reviewer Reviewer B.", + "action": "Redact the reviewer mention or move the message to a reviewer-only thread.", + "evidence": { + "threadId": "thread-1", + "mentionId": "u-reviewer" + } + }, + { + "severity": "hold", + "id": "notebook-cell-scope:thread-1:u-reviewer", + "message": "Reviewer B is mentioned on notebook cell cell-qc-4 without cell access.", + "action": "Grant cell access or move the mention to a document-level thread.", + "evidence": { + "threadId": "thread-1", + "notebookCellId": "cell-qc-4", + "mentionId": "u-reviewer" + } + }, + { + "severity": "hold", + "id": "section-scope:thread-1:u-contractor", + "message": "External Analyst is mentioned outside their allowed section scope.", + "action": "Invite the collaborator to the section or remove the mention.", + "evidence": { + "threadId": "thread-1", + "sectionId": "methods", + "mentionId": "u-contractor" + } + }, + { + "severity": "block", + "id": "external-restricted-section:thread-1:u-contractor", + "message": "External collaborator External Analyst is mentioned in restricted section methods.", + "action": "Remove the mention or approve restricted-section access before fanout.", + "evidence": { + "threadId": "thread-1", + "sectionId": "methods", + "mentionId": "u-contractor" + } + }, + { + "severity": "hold", + "id": "notebook-cell-scope:thread-1:u-contractor", + "message": "External Analyst is mentioned on notebook cell cell-qc-4 without cell access.", + "action": "Grant cell access or move the mention to a document-level thread.", + "evidence": { + "threadId": "thread-1", + "notebookCellId": "cell-qc-4", + "mentionId": "u-contractor" + } + }, + { + "severity": "hold", + "id": "locked-section-mention:thread-1", + "message": "Thread thread-1 mentions collaborators inside locked section methods without owner approval.", + "action": "Ask the section owner to approve the mention fanout or defer it until unlock.", + "evidence": { + "threadId": "thread-1", + "sectionId": "methods" + } + }, + { + "severity": "hold", + "id": "private-title-preview:thread-1", + "message": "Thread thread-1 fanout preview includes a private section title.", + "action": "Redact the section title in the mention preview before alerting collaborators.", + "evidence": { + "threadId": "thread-1", + "sectionId": "methods" + } + } + ] +} diff --git a/collaborative-chat-mention-scope-guard/reports/mention-scope-report.md b/collaborative-chat-mention-scope-guard/reports/mention-scope-report.md new file mode 100644 index 00000000..bf3cc1cb --- /dev/null +++ b/collaborative-chat-mention-scope-guard/reports/mention-scope-report.md @@ -0,0 +1,15 @@ +# Chat Mention Scope Guard Report + +Workspace: editor-room-chat-118 +Decision: block-mention-fanout + +| Severity | Finding | Action | +| --- | --- | --- | +| hold | Reviewer B is mentioned outside their allowed section scope. | Invite the collaborator to the section or remove the mention. | +| block | Author-visible thread thread-1 would reveal blinded reviewer Reviewer B. | Redact the reviewer mention or move the message to a reviewer-only thread. | +| hold | Reviewer B is mentioned on notebook cell cell-qc-4 without cell access. | Grant cell access or move the mention to a document-level thread. | +| hold | External Analyst is mentioned outside their allowed section scope. | Invite the collaborator to the section or remove the mention. | +| block | External collaborator External Analyst is mentioned in restricted section methods. | Remove the mention or approve restricted-section access before fanout. | +| hold | External Analyst is mentioned on notebook cell cell-qc-4 without cell access. | Grant cell access or move the mention to a document-level thread. | +| hold | Thread thread-1 mentions collaborators inside locked section methods without owner approval. | Ask the section owner to approve the mention fanout or defer it until unlock. | +| hold | Thread thread-1 fanout preview includes a private section title. | Redact the section title in the mention preview before alerting collaborators. | diff --git a/collaborative-chat-mention-scope-guard/reports/summary.svg b/collaborative-chat-mention-scope-guard/reports/summary.svg new file mode 100644 index 00000000..d04c6d0d --- /dev/null +++ b/collaborative-chat-mention-scope-guard/reports/summary.svg @@ -0,0 +1,8 @@ + + + +Chat Mention Scope Guard +Decision: block-mention-fanout +Blockers: 2 | Holds: 6 | Threads: 2 +Workspace editor-room-chat-118 + \ No newline at end of file diff --git a/collaborative-chat-mention-scope-guard/sampleWorkspace.js b/collaborative-chat-mention-scope-guard/sampleWorkspace.js new file mode 100644 index 00000000..bfbabd94 --- /dev/null +++ b/collaborative-chat-mention-scope-guard/sampleWorkspace.js @@ -0,0 +1,49 @@ +module.exports = { + id: "editor-room-chat-118", + users: [ + { id: "u-author", displayName: "Dr. Author", role: "author", external: false }, + { id: "u-stat", displayName: "Dr. Statistician", role: "collaborator", external: false }, + { id: "u-reviewer", displayName: "Reviewer B", role: "blinded-reviewer", external: false }, + { id: "u-contractor", displayName: "External Analyst", role: "collaborator", external: true } + ], + sections: [ + { + id: "methods", + title: "Restricted Assay Methods", + members: ["u-author", "u-stat"], + restricted: true, + locked: true, + privateTitle: true + }, + { + id: "discussion", + title: "Discussion", + members: ["u-author", "u-stat", "u-contractor"], + restricted: false, + locked: false, + privateTitle: false + } + ], + notebookCells: [ + { id: "cell-qc-4", allowedUsers: ["u-author", "u-stat"] } + ], + chatThreads: [ + { + id: "thread-1", + sectionId: "methods", + visibility: "author-visible", + mentions: ["u-reviewer", "u-contractor", "u-stat"], + notebookCellId: "cell-qc-4", + sectionOwnerApproved: false, + fanoutPreview: "New mention in Restricted Assay Methods" + }, + { + id: "thread-2", + sectionId: "discussion", + visibility: "project-visible", + mentions: ["u-contractor"], + sectionOwnerApproved: true, + fanoutPreview: "New mention in Discussion" + } + ] +}; diff --git a/collaborative-chat-mention-scope-guard/test.js b/collaborative-chat-mention-scope-guard/test.js new file mode 100644 index 00000000..0a1a58ef --- /dev/null +++ b/collaborative-chat-mention-scope-guard/test.js @@ -0,0 +1,27 @@ +const assert = require("assert"); +const { evaluateMentionFanout } = require("./mentionScopeGuard"); +const sampleWorkspace = require("./sampleWorkspace"); + +const result = evaluateMentionFanout(sampleWorkspace); + +assert.equal(result.decision, "block-mention-fanout"); +assert.ok(result.findings.some((finding) => finding.id === "section-scope:thread-1:u-reviewer")); +assert.ok(result.findings.some((finding) => finding.id === "blinded-reviewer-leak:thread-1:u-reviewer")); +assert.ok(result.findings.some((finding) => finding.id === "section-scope:thread-1:u-contractor")); +assert.ok(result.findings.some((finding) => finding.id === "external-restricted-section:thread-1:u-contractor")); +assert.ok(result.findings.some((finding) => finding.id === "notebook-cell-scope:thread-1:u-contractor")); +assert.ok(result.findings.some((finding) => finding.id === "locked-section-mention:thread-1")); +assert.ok(result.findings.some((finding) => finding.id === "private-title-preview:thread-1")); + +const cleanResult = evaluateMentionFanout({ + id: "clean-editor-room", + users: [{ id: "u-a", displayName: "Author", role: "author", external: false }], + sections: [{ id: "intro", title: "Intro", members: ["u-a"], restricted: false, locked: false, privateTitle: false }], + notebookCells: [], + chatThreads: [{ id: "t-ok", sectionId: "intro", visibility: "project-visible", mentions: ["u-a"], sectionOwnerApproved: true, fanoutPreview: "New mention in Intro" }] +}); + +assert.equal(cleanResult.decision, "ready-for-mention-fanout"); +assert.equal(cleanResult.findings.length, 0); + +console.log("chat mention scope guard tests passed");