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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions collaborative-chat-mention-scope-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions collaborative-chat-mention-scope-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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}`);
160 changes: 160 additions & 0 deletions collaborative-chat-mention-scope-guard/mentionScopeGuard.js
Original file line number Diff line number Diff line change
@@ -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 [
`<svg xmlns="http://www.w3.org/2000/svg" width="760" height="220" viewBox="0 0 760 220" role="img" aria-label="Mention scope guard summary">`,
`<rect width="760" height="220" fill="#f8fafc"/>`,
`<rect x="30" y="28" width="700" height="164" rx="8" fill="#ffffff" stroke="#d0d7de"/>`,
`<text x="54" y="72" font-family="Arial, sans-serif" font-size="24" font-weight="700" fill="#111827">Chat Mention Scope Guard</text>`,
`<text x="54" y="112" font-family="Arial, sans-serif" font-size="18" fill="${color}">Decision: ${result.decision}</text>`,
`<text x="54" y="146" font-family="Arial, sans-serif" font-size="16" fill="#374151">Blockers: ${result.summary.blockers} | Holds: ${result.summary.holds} | Threads: ${result.summary.threads}</text>`,
`<text x="54" y="174" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Workspace ${result.workspaceId}</text>`,
`</svg>`
].join("\n");
}

module.exports = {
evaluateMentionFanout,
toMarkdown,
toSvg
};
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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. |
8 changes: 8 additions & 0 deletions collaborative-chat-mention-scope-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions collaborative-chat-mention-scope-guard/sampleWorkspace.js
Original file line number Diff line number Diff line change
@@ -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"
}
]
};
Loading