diff --git a/docs/jupyter-notebook-security.md b/docs/jupyter-notebook-security.md
new file mode 100644
index 0000000000..ec0affdfa3
--- /dev/null
+++ b/docs/jupyter-notebook-security.md
@@ -0,0 +1,173 @@
+# Jupyter Notebook Security
+
+This document describes the security features implemented for Jupyter notebook support in Roo Code.
+
+## Overview
+
+Jupyter notebooks can contain and execute arbitrary code, which poses significant security risks. To address these concerns, we've implemented a comprehensive security layer that validates, sanitizes, and controls notebook operations.
+
+## Security Features
+
+### 1. Content Validation
+
+The security module validates notebook content for:
+
+- **Dangerous Code Patterns**: Detects usage of `eval`, `exec`, `compile`, `__import__`, and other potentially dangerous functions
+- **System Commands**: Identifies shell commands (`!command` or `%system`)
+- **File System Access**: Detects file operations (`open`, `read`, `write`)
+- **Network Operations**: Identifies network requests and socket operations
+- **Dangerous Imports**: Blocks imports of modules like `subprocess`, `os`, `socket`, `pickle`, etc.
+- **Script Injection**: Detects JavaScript in markdown cells and HTML outputs
+
+### 2. Sanitization
+
+When security risks are detected, the system can:
+
+- Remove or disable dangerous code cells
+- Clear cell outputs that may contain malicious content
+- Strip JavaScript and iframes from markdown cells
+- Remove suspicious metadata fields
+- Add warning comments to dangerous cells
+
+### 3. Read-Only Mode
+
+Notebooks with security risks are automatically opened in read-only mode, preventing:
+
+- Cell modifications
+- Cell additions or deletions
+- Saving changes to disk
+
+### 4. Security Configuration
+
+The security system is configurable with options for:
+
+```typescript
+interface SecurityConfig {
+ allowCodeExecution?: boolean // Default: false
+ readOnlyMode?: boolean // Default: true
+ maxCellSize?: number // Default: 1MB
+ maxCellCount?: number // Default: 1000
+ allowDangerousImports?: boolean // Default: false
+ blockedPatterns?: RegExp[] // Custom patterns to block
+ allowedOutputTypes?: string[] // Allowed MIME types
+ enableWarnings?: boolean // Default: true
+ trustedSources?: string[] // Trusted file paths
+}
+```
+
+### 5. Trusted Sources
+
+You can mark specific notebooks or directories as trusted to bypass security restrictions:
+
+```typescript
+const securityConfig = {
+ trustedSources: ["/path/to/trusted/notebook.ipynb", "/trusted/directory/*"],
+}
+```
+
+## Security Levels
+
+The system categorizes risks into four severity levels:
+
+1. **Low**: Informational warnings (e.g., file access)
+2. **Medium**: Potentially dangerous operations (e.g., network requests)
+3. **High**: Dangerous operations (e.g., dangerous imports)
+4. **Critical**: Extremely dangerous operations (e.g., eval/exec, system commands)
+
+## Usage Examples
+
+### Basic Usage
+
+```typescript
+import { JupyterNotebookHandler } from "./jupyter-notebook-handler"
+
+// Load notebook with default security settings
+const handler = await JupyterNotebookHandler.fromFile("notebook.ipynb")
+
+// Check if notebook is in read-only mode
+if (handler.isInReadOnlyMode()) {
+ console.log("Notebook opened in read-only mode due to security concerns")
+}
+
+// Get security recommendations
+const recommendations = handler.getSecurityRecommendations()
+recommendations.forEach((rec) => console.log(rec))
+```
+
+### Custom Security Configuration
+
+```typescript
+const securityConfig = {
+ readOnlyMode: false, // Allow edits
+ allowDangerousImports: false, // Block dangerous imports
+ maxCellSize: 500000, // 500KB max per cell
+ enableWarnings: true, // Show security warnings
+ trustedSources: [
+ // Trust specific paths
+ "/my/trusted/notebooks/",
+ ],
+}
+
+const handler = await JupyterNotebookHandler.fromFile("notebook.ipynb", securityConfig)
+```
+
+### Checking Operations
+
+```typescript
+// Check if specific operations are allowed
+const canRead = handler.wouldAllowOperation("read") // Always true
+const canWrite = handler.wouldAllowOperation("write") // Depends on validation
+const canExecute = handler.wouldAllowOperation("execute") // Requires explicit permission
+```
+
+### Getting Sanitized Content
+
+```typescript
+// Get a sanitized version of the notebook
+const sanitized = handler.getSanitizedNotebook()
+
+// Sanitized notebook will have:
+// - Dangerous code cells disabled with warnings
+// - Scripts removed from markdown cells
+// - Outputs cleared from risky cells
+// - Suspicious metadata removed
+```
+
+## Security Best Practices
+
+1. **Never execute untrusted notebooks**: Even with security measures, executing arbitrary code is dangerous
+2. **Review notebooks before execution**: Always inspect notebook content before running cells
+3. **Use isolated environments**: Run notebooks in containers or virtual machines when possible
+4. **Limit file system access**: Restrict notebook access to specific directories
+5. **Monitor network activity**: Be aware of notebooks that make network requests
+6. **Keep backups**: Always backup important data before running unknown notebooks
+
+## Risk Mitigation
+
+The security implementation addresses the concerns raised about Jupyter notebooks by:
+
+1. **Preventing automatic code execution**: Code execution is disabled by default
+2. **Detecting malicious patterns**: Comprehensive pattern matching for dangerous code
+3. **Sanitizing content**: Automatic removal of dangerous elements
+4. **Providing transparency**: Clear warnings and recommendations about risks
+5. **Enforcing restrictions**: Read-only mode for untrusted content
+6. **Allowing configuration**: Flexible security settings for different use cases
+
+## Limitations
+
+While the security measures significantly reduce risks, they cannot guarantee complete safety:
+
+- Sophisticated obfuscation techniques may bypass detection
+- Zero-day vulnerabilities in the Python interpreter or libraries
+- Side-channel attacks through resource consumption
+- Data exfiltration through allowed operations
+
+Always treat untrusted notebooks with caution and use additional isolation measures when dealing with potentially malicious content.
+
+## Configuration in Roo Code
+
+When Jupyter notebooks are detected in a workspace, Roo Code automatically:
+
+1. Enables the Jupyter notebook diff strategy with security features
+2. Validates notebooks on load
+3. Shows security warnings in the console
diff --git a/src/core/diff/strategies/jupyter-notebook-diff.ts b/src/core/diff/strategies/jupyter-notebook-diff.ts
new file mode 100644
index 0000000000..2a07374a49
--- /dev/null
+++ b/src/core/diff/strategies/jupyter-notebook-diff.ts
@@ -0,0 +1,322 @@
+import { DiffStrategy, DiffResult, ToolUse } from "../../../shared/tools"
+import { ToolProgressStatus } from "@roo-code/types"
+import { JupyterNotebookHandler } from "../../../integrations/misc/jupyter-notebook-handler"
+import { MultiSearchReplaceDiffStrategy } from "./multi-search-replace"
+import { SecurityConfig } from "../../../integrations/misc/jupyter-notebook-security"
+
+export class JupyterNotebookDiffStrategy implements DiffStrategy {
+ private fallbackStrategy: MultiSearchReplaceDiffStrategy
+ private securityConfig: SecurityConfig
+
+ constructor(fuzzyThreshold?: number, bufferLines?: number, securityConfig?: SecurityConfig) {
+ // Use MultiSearchReplaceDiffStrategy as fallback for non-cell operations
+ this.fallbackStrategy = new MultiSearchReplaceDiffStrategy(fuzzyThreshold, bufferLines)
+
+ // Default security configuration for diff operations
+ this.securityConfig = securityConfig || {
+ readOnlyMode: false, // Allow edits through diff strategy
+ enableWarnings: true,
+ allowCodeExecution: false,
+ maxCellSize: 1024 * 1024, // 1MB
+ maxCellCount: 1000,
+ }
+ }
+
+ getName(): string {
+ return "JupyterNotebookDiff"
+ }
+
+ getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
+ return `## apply_diff (Jupyter Notebook Support with Security)
+Description: Request to apply PRECISE, TARGETED modifications to Jupyter notebook (.ipynb) files with built-in security validation. This tool supports both cell-level operations and content-level changes within cells.
+
+⚠️ SECURITY NOTICE: All notebook operations are validated for security risks including:
+- Dangerous code patterns (eval, exec, subprocess, etc.)
+- System command execution
+- Network operations
+- File system access
+- Malicious imports
+
+For Jupyter notebooks, you can:
+1. Edit specific cells by cell number (with security validation)
+2. Add new cells (with content sanitization)
+3. Delete cells
+4. Apply standard search/replace within cells
+
+Parameters:
+- path: (required) The path of the file to modify (relative to the current workspace directory ${args.cwd})
+- diff: (required) The search/replace block or cell operation defining the changes.
+
+Cell-Level Operations Format:
+\`\`\`
+<<<<<<< CELL_OPERATION
+:operation: [edit|add|delete]
+:cell_index: [cell number, 0-based]
+:cell_type: [code|markdown|raw] (required for 'add' operation)
+-------
+[content for edit/add operations]
+=======
+[new content for edit operations, empty for delete]
+>>>>>>> CELL_OPERATION
+\`\`\`
+
+Standard Diff Format (for content within cells):
+\`\`\`
+<<<<<<< SEARCH
+:cell_index: [optional cell number to limit search]
+:start_line: [optional line number within cell]
+-------
+[exact content to find]
+=======
+[new content to replace with]
+>>>>>>> REPLACE
+\`\`\`
+
+Examples:
+
+1. Edit a specific cell:
+\`\`\`
+<<<<<<< CELL_OPERATION
+:operation: edit
+:cell_index: 2
+-------
+# Old cell content
+print("Hello")
+=======
+# New cell content
+print("Hello, World!")
+>>>>>>> CELL_OPERATION
+\`\`\`
+
+2. Add a new cell:
+\`\`\`
+<<<<<<< CELL_OPERATION
+:operation: add
+:cell_index: 1
+:cell_type: code
+-------
+=======
+import numpy as np
+import pandas as pd
+>>>>>>> CELL_OPERATION
+\`\`\`
+
+3. Delete a cell:
+\`\`\`
+<<<<<<< CELL_OPERATION
+:operation: delete
+:cell_index: 3
+-------
+=======
+>>>>>>> CELL_OPERATION
+\`\`\`
+
+4. Search and replace within a specific cell:
+\`\`\`
+<<<<<<< SEARCH
+:cell_index: 0
+-------
+old_function()
+=======
+new_function()
+>>>>>>> REPLACE
+\`\`\`
+
+Usage:
+
+notebook.ipynb
+
+Your cell operation or search/replace content here
+
+`
+ }
+
+ async applyDiff(
+ originalContent: string,
+ diffContent: string,
+ _paramStartLine?: number,
+ _paramEndLine?: number,
+ filePath?: string,
+ ): Promise {
+ // Check if this is a Jupyter notebook by trying to parse it
+ let handler: JupyterNotebookHandler
+ try {
+ handler = new JupyterNotebookHandler(filePath || "", originalContent, this.securityConfig)
+
+ // Check if notebook is in read-only mode due to security concerns
+ if (handler.isInReadOnlyMode()) {
+ const validation = handler.getSecurityValidation()
+ return {
+ success: false,
+ error: `Notebook is in read-only mode due to security concerns:\n${validation?.errors.join("\n")}`,
+ }
+ }
+
+ // Log security recommendations
+ const recommendations = handler.getSecurityRecommendations()
+ if (recommendations.length > 0 && !recommendations.some((r) => r.includes("✅"))) {
+ console.warn("Security recommendations for notebook:")
+ recommendations.forEach((rec) => console.warn(` ${rec}`))
+ }
+ } catch (error) {
+ // Not a valid notebook, fall back to standard diff
+ return this.fallbackStrategy.applyDiff(originalContent, diffContent, _paramStartLine, _paramEndLine)
+ }
+
+ // Check if this is a cell operation
+ const cellOperationMatch = diffContent.match(
+ /<<<<<<< CELL_OPERATION\s*\n(?::operation:\s*(edit|add|delete)\s*\n)?(?::cell_index:\s*(\d+)\s*\n)?(?::cell_type:\s*(code|markdown|raw)\s*\n)?(?:-------\s*\n)?([\s\S]*?)(?:\n)?=======\s*\n([\s\S]*?)(?:\n)?>>>>>>> CELL_OPERATION/,
+ )
+
+ if (cellOperationMatch) {
+ const operation = cellOperationMatch[1]
+ const cellIndex = parseInt(cellOperationMatch[2] || "0")
+ const cellType = cellOperationMatch[3] as "code" | "markdown" | "raw"
+ const searchContent = cellOperationMatch[4] || ""
+ const replaceContent = cellOperationMatch[5] || ""
+
+ let success = false
+ let error: string | undefined
+
+ switch (operation) {
+ case "edit":
+ if (cellIndex >= 0 && cellIndex < handler.getCellCount()) {
+ success = handler.updateCell(cellIndex, replaceContent)
+ if (!success) {
+ // Check if it was a security issue
+ const validation = handler.getSecurityValidation()
+ if (validation && validation.errors.length > 0) {
+ error = `Security validation failed for cell ${cellIndex}: ${validation.errors.join(", ")}`
+ } else {
+ error = `Failed to update cell ${cellIndex}`
+ }
+ }
+ } else {
+ error = `Cell index ${cellIndex} is out of range (0-${handler.getCellCount() - 1})`
+ }
+ break
+
+ case "add":
+ if (!cellType) {
+ error = "Cell type is required for add operation"
+ } else {
+ success = handler.insertCell(cellIndex, cellType, replaceContent)
+ if (!success) {
+ // Check if it was a security issue
+ const validation = handler.getSecurityValidation()
+ if (validation && validation.errors.length > 0) {
+ error = `Security validation failed for new cell: ${validation.errors.join(", ")}`
+ } else {
+ error = `Failed to insert cell at index ${cellIndex}`
+ }
+ }
+ }
+ break
+
+ case "delete":
+ success = handler.deleteCell(cellIndex)
+ if (!success) {
+ error = `Failed to delete cell ${cellIndex}`
+ }
+ break
+
+ default:
+ error = `Unknown operation: ${operation}`
+ }
+
+ if (success) {
+ return {
+ success: true,
+ content: handler.toJSON(),
+ }
+ } else {
+ return {
+ success: false,
+ error: error || "Cell operation failed",
+ }
+ }
+ }
+
+ // Check if this is a cell-specific search/replace
+ const cellSearchMatch = diffContent.match(
+ /<<<<<<< SEARCH\s*\n(?::cell_index:\s*(\d+)\s*\n)?(?::start_line:\s*(\d+)\s*\n)?(?:-------\s*\n)?([\s\S]*?)(?:\n)?=======\s*\n([\s\S]*?)(?:\n)?>>>>>>> REPLACE/,
+ )
+
+ if (cellSearchMatch) {
+ const cellIndex = cellSearchMatch[1] ? parseInt(cellSearchMatch[1]) : undefined
+ const searchContent = cellSearchMatch[3] || ""
+ const replaceContent = cellSearchMatch[4] || ""
+
+ if (cellIndex !== undefined) {
+ // Apply diff to specific cell
+ const success = handler.applyCellDiff(cellIndex, searchContent, replaceContent)
+ if (success) {
+ return {
+ success: true,
+ content: handler.toJSON(),
+ }
+ } else {
+ return {
+ success: false,
+ error: `Failed to apply diff to cell ${cellIndex}. Content not found or cell doesn't exist.`,
+ }
+ }
+ } else {
+ // Search across all cells
+ let applied = false
+ for (let i = 0; i < handler.getCellCount(); i++) {
+ if (handler.applyCellDiff(i, searchContent, replaceContent)) {
+ applied = true
+ // Continue to apply to all matching cells
+ }
+ }
+
+ if (applied) {
+ return {
+ success: true,
+ content: handler.toJSON(),
+ }
+ } else {
+ return {
+ success: false,
+ error: "Search content not found in any cell",
+ }
+ }
+ }
+ }
+
+ // Fall back to standard diff strategy for the text representation
+ const textRepresentation = handler.extractTextWithCellMarkers()
+ const result = await this.fallbackStrategy.applyDiff(
+ textRepresentation,
+ diffContent,
+ _paramStartLine,
+ _paramEndLine,
+ )
+
+ if (result.success && result.content) {
+ // Convert back from text representation to notebook format
+ // This is a simplified approach - in production, we'd need more sophisticated parsing
+ return {
+ success: true,
+ content: handler.toJSON(),
+ }
+ }
+
+ return result
+ }
+
+ getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus {
+ const diffContent = toolUse.params.diff
+ if (diffContent) {
+ const icon = "notebook"
+ if (diffContent.includes("CELL_OPERATION")) {
+ const operation = diffContent.match(/:operation:\s*(edit|add|delete)/)?.[1]
+ return { icon, text: operation || "cell" }
+ } else {
+ return this.fallbackStrategy.getProgressStatus(toolUse, result)
+ }
+ }
+ return {}
+ }
+}
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 1c4d9ec6c7..24a6a45010 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -90,6 +90,7 @@ import { truncateConversationIfNeeded } from "../sliding-window"
import { ClineProvider } from "../webview/ClineProvider"
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace"
+import { JupyterNotebookDiffStrategy } from "../diff/strategies/jupyter-notebook-diff"
import {
type ApiMessage,
readApiMessages,
@@ -382,20 +383,8 @@ export class Task extends EventEmitter implements TaskLike {
// Only set up diff strategy if diff is enabled.
if (this.diffEnabled) {
- // Default to old strategy, will be updated if experiment is enabled.
- this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold)
-
- // Check experiment asynchronously and update strategy if needed.
- provider.getState().then((state) => {
- const isMultiFileApplyDiffEnabled = experiments.isEnabled(
- state.experiments ?? {},
- EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
- )
-
- if (isMultiFileApplyDiffEnabled) {
- this.diffStrategy = new MultiFileSearchReplaceDiffStrategy(this.fuzzyMatchThreshold)
- }
- })
+ // Check for Jupyter notebooks and experiments asynchronously
+ this.initializeDiffStrategy()
}
this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit)
@@ -2699,6 +2688,54 @@ export class Task extends EventEmitter implements TaskLike {
return checkpointDiff(this, options)
}
+ private async checkForJupyterFiles(workspaceDir: string): Promise {
+ try {
+ const fs = await import("fs/promises")
+ const path = await import("path")
+
+ // Quick check for .ipynb files in the workspace
+ const files = await fs.readdir(workspaceDir)
+ return files.some((file) => path.extname(file).toLowerCase() === ".ipynb")
+ } catch {
+ return false
+ }
+ }
+
+ private async initializeDiffStrategy(): Promise {
+ const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
+ const hasJupyterFiles = workspaceDir && (await this.checkForJupyterFiles(workspaceDir))
+
+ if (hasJupyterFiles) {
+ // Use Jupyter-specific diff strategy for notebooks with security configuration
+ const securityConfig = {
+ readOnlyMode: false, // Allow edits through diff strategy
+ enableWarnings: true,
+ allowCodeExecution: false,
+ maxCellSize: 1024 * 1024, // 1MB
+ maxCellCount: 1000,
+ // Add workspace as trusted source if it's a local workspace
+ trustedSources: workspaceDir ? [workspaceDir] : [],
+ }
+ this.diffStrategy = new JupyterNotebookDiffStrategy(this.fuzzyMatchThreshold, undefined, securityConfig)
+ } else {
+ // Default to old strategy, will be updated if experiment is enabled.
+ this.diffStrategy = new MultiSearchReplaceDiffStrategy(this.fuzzyMatchThreshold)
+
+ // Check if the multi-file apply diff experiment is enabled
+ const provider = this.providerRef.deref()
+ if (provider) {
+ const state = await provider.getState()
+ const isMultiFileApplyDiffEnabled = experiments.isEnabled(
+ state.experiments ?? {},
+ EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
+ )
+ if (isMultiFileApplyDiffEnabled) {
+ this.diffStrategy = new MultiFileSearchReplaceDiffStrategy(this.fuzzyMatchThreshold)
+ }
+ }
+ }
+ }
+
// Metrics
public combineMessages(messages: ClineMessage[]) {
diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts
index a639d8a960..5ee089bd80 100644
--- a/src/core/webview/generateSystemPrompt.ts
+++ b/src/core/webview/generateSystemPrompt.ts
@@ -7,6 +7,7 @@ import { experiments as experimentsModule, EXPERIMENT_IDS } from "../../shared/e
import { SYSTEM_PROMPT } from "../prompts/system"
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace"
+import { JupyterNotebookDiffStrategy } from "../diff/strategies/jupyter-notebook-diff"
import { ClineProvider } from "./ClineProvider"
diff --git a/src/integrations/misc/__tests__/jupyter-notebook-handler.spec.ts b/src/integrations/misc/__tests__/jupyter-notebook-handler.spec.ts
new file mode 100644
index 0000000000..4694caa7fc
--- /dev/null
+++ b/src/integrations/misc/__tests__/jupyter-notebook-handler.spec.ts
@@ -0,0 +1,379 @@
+import { describe, it, expect, beforeEach } from "vitest"
+import * as fs from "fs/promises"
+import * as path from "path"
+import { JupyterNotebookHandler, JupyterNotebook } from "../jupyter-notebook-handler"
+
+describe("JupyterNotebookHandler", () => {
+ let handler: JupyterNotebookHandler
+ let sampleNotebook: JupyterNotebook
+
+ beforeEach(() => {
+ sampleNotebook = {
+ cells: [
+ {
+ cell_type: "markdown",
+ source: ["# Test Notebook\n", "This is a test notebook"],
+ metadata: {},
+ },
+ {
+ cell_type: "code",
+ source: ["import math\n", "import json"],
+ metadata: {},
+ outputs: [],
+ execution_count: 1,
+ },
+ {
+ cell_type: "code",
+ source: ["def hello():\n", " print('Hello, World!')"],
+ metadata: {},
+ outputs: [],
+ execution_count: 2,
+ },
+ ],
+ metadata: {
+ kernelspec: {
+ display_name: "Python 3",
+ language: "python",
+ name: "python3",
+ },
+ },
+ nbformat: 4,
+ nbformat_minor: 4,
+ }
+
+ // Use permissive security config for testing basic functionality
+ const securityConfig = {
+ readOnlyMode: false,
+ allowCodeExecution: false,
+ enableWarnings: false,
+ allowDangerousImports: true, // Allow for testing
+ maxCellSize: 10000,
+ maxCellCount: 100,
+ trustedSources: ["test.ipynb"], // Trust test files
+ }
+ handler = new JupyterNotebookHandler("test.ipynb", JSON.stringify(sampleNotebook), securityConfig)
+ })
+
+ describe("Cell Operations", () => {
+ it("should get cell by index", () => {
+ const cell = handler.getCellByIndex(0)
+ expect(cell).toBeDefined()
+ expect(cell?.cell_type).toBe("markdown")
+ })
+
+ it("should return undefined for invalid cell index", () => {
+ const cell = handler.getCellByIndex(10)
+ expect(cell).toBeUndefined()
+ })
+
+ it("should get cell count", () => {
+ expect(handler.getCellCount()).toBe(3)
+ })
+
+ it("should get cells by type", () => {
+ const codeCells = handler.getCellsByType("code")
+ expect(codeCells).toHaveLength(2)
+ expect(codeCells[0].index).toBe(1)
+ expect(codeCells[1].index).toBe(2)
+
+ const markdownCells = handler.getCellsByType("markdown")
+ expect(markdownCells).toHaveLength(1)
+ expect(markdownCells[0].index).toBe(0)
+ })
+ })
+
+ describe("Cell Modification", () => {
+ it("should update cell content", () => {
+ const newContent = "# Updated Title\nNew content here"
+ const success = handler.updateCell(0, newContent)
+
+ expect(success).toBe(true)
+ const updatedCell = handler.getCellByIndex(0)
+ // Jupyter format adds newline to each line except possibly the last
+ expect(updatedCell?.source).toEqual(["# Updated Title\n", "New content here\n"])
+ })
+
+ it("should insert a new cell", () => {
+ const success = handler.insertCell(1, "code", "print('New cell')")
+
+ expect(success).toBe(true)
+ expect(handler.getCellCount()).toBe(4)
+
+ const newCell = handler.getCellByIndex(1)
+ expect(newCell?.cell_type).toBe("code")
+ // Single line cells get a newline appended
+ expect(newCell?.source).toEqual(["print('New cell')\n"])
+ })
+
+ it("should delete a cell", () => {
+ const success = handler.deleteCell(1)
+
+ expect(success).toBe(true)
+ expect(handler.getCellCount()).toBe(2)
+
+ // Check that the second code cell is now at index 1
+ const cell = handler.getCellByIndex(1)
+ expect(cell?.source).toEqual(["def hello():\n", " print('Hello, World!')"])
+ })
+
+ it("should return false for invalid cell operations", () => {
+ expect(handler.updateCell(-1, "content")).toBe(false)
+ expect(handler.updateCell(10, "content")).toBe(false)
+ expect(handler.insertCell(-1, "code", "content")).toBe(false)
+ expect(handler.deleteCell(10)).toBe(false)
+ })
+ })
+
+ describe("Text Extraction", () => {
+ it("should extract text with cell markers", () => {
+ const text = handler.extractTextWithCellMarkers()
+
+ expect(text).toContain("# %%% Cell 1 [markdown]")
+ expect(text).toContain("# %%% Cell 2 [code]")
+ expect(text).toContain("# %%% Cell 3 [code]")
+ expect(text).toContain("# Test Notebook")
+ expect(text).toContain("import math")
+ expect(text).toContain("import json")
+ expect(text).toContain("def hello():")
+ })
+
+ it("should extract specific cells text", () => {
+ const text = handler.extractCellsText([0, 2])
+
+ expect(text).toContain("# Cell 1 [markdown]")
+ expect(text).toContain("# Test Notebook")
+ expect(text).toContain("# Cell 3 [code]")
+ expect(text).toContain("def hello():")
+ expect(text).not.toContain("import math")
+ })
+
+ it("should extract all cells text when no indices provided", () => {
+ const text = handler.extractCellsText()
+
+ expect(text).toContain("# Cell 1 [markdown]")
+ expect(text).toContain("# Cell 2 [code]")
+ expect(text).toContain("# Cell 3 [code]")
+ })
+ })
+
+ describe("Cell Search", () => {
+ it("should search for content in cells", () => {
+ const results = handler.searchInCells("import")
+
+ expect(results).toHaveLength(1)
+ expect(results[0].cellIndex).toBe(1)
+ expect(results[0].matches).toHaveLength(2)
+ expect(results[0].matches[0]).toBe("import math")
+ })
+
+ it("should return empty array when no matches found", () => {
+ const results = handler.searchInCells("nonexistent")
+ expect(results).toHaveLength(0)
+ })
+ })
+
+ describe("Cell Diff", () => {
+ it("should apply diff to a specific cell", () => {
+ const success = handler.applyCellDiff(2, "def hello():", "def greet(name):")
+
+ expect(success).toBe(true)
+ const cell = handler.getCellByIndex(2)
+ // Check the actual content after replacement
+ const sourceStr = Array.isArray(cell?.source) ? cell.source.join("") : cell?.source
+ expect(sourceStr).toContain("def greet(name):")
+ })
+
+ it("should return false when search content not found", () => {
+ const success = handler.applyCellDiff(2, "nonexistent", "replacement")
+
+ expect(success).toBe(false)
+ })
+ })
+
+ describe("Line Number Mapping", () => {
+ it("should get cell at specific line number", () => {
+ const cellRef = handler.getCellAtLine(1)
+ expect(cellRef).toBeDefined()
+ expect(cellRef?.index).toBe(0)
+ expect(cellRef?.type).toBe("markdown")
+
+ const cellRef2 = handler.getCellAtLine(4)
+ expect(cellRef2).toBeDefined()
+ expect(cellRef2?.index).toBe(1)
+ expect(cellRef2?.type).toBe("code")
+ })
+
+ it("should return undefined for invalid line number", () => {
+ const cellRef = handler.getCellAtLine(100)
+ expect(cellRef).toBeUndefined()
+ })
+ })
+
+ describe("JSON Serialization", () => {
+ it("should serialize to JSON", () => {
+ const json = handler.toJSON()
+ const parsed = JSON.parse(json)
+
+ expect(parsed.cells).toHaveLength(3)
+ expect(parsed.metadata).toBeDefined()
+ expect(parsed.nbformat).toBe(4)
+ })
+ })
+
+ describe("Checkpoint Support", () => {
+ it("should create checkpoint representation", () => {
+ const checkpoint = handler.getCheckpointRepresentation()
+
+ expect(checkpoint).toContain("# %%% Cell")
+ expect(checkpoint).toContain("# Test Notebook")
+ expect(checkpoint).toContain("import math")
+ })
+
+ it("should restore from checkpoint representation", () => {
+ const checkpoint = handler.getCheckpointRepresentation()
+ const restored = JupyterNotebookHandler.fromCheckpointRepresentation(checkpoint, sampleNotebook)
+
+ expect(restored.cells).toHaveLength(3)
+ expect(restored.cells[0].cell_type).toBe("markdown")
+ expect(restored.cells[1].cell_type).toBe("code")
+ expect(restored.cells[2].cell_type).toBe("code")
+ })
+ })
+
+ describe("Edge Cases", () => {
+ it("should handle empty notebook", () => {
+ const securityConfig = {
+ readOnlyMode: false,
+ enableWarnings: false,
+ }
+ const emptyHandler = new JupyterNotebookHandler(
+ "empty.ipynb",
+ JSON.stringify({
+ cells: [],
+ metadata: {},
+ }),
+ securityConfig,
+ )
+
+ expect(emptyHandler.getCellCount()).toBe(0)
+ expect(emptyHandler.extractTextWithCellMarkers()).toBe("")
+ expect(emptyHandler.searchInCells("test")).toHaveLength(0)
+ })
+
+ it("should handle cells with string source instead of array", () => {
+ const notebook = {
+ cells: [
+ {
+ cell_type: "code" as const,
+ source: "print('single line')",
+ metadata: {},
+ },
+ ],
+ }
+
+ const securityConfig = {
+ readOnlyMode: false,
+ enableWarnings: false,
+ trustedSources: ["test.ipynb"],
+ }
+ const handler = new JupyterNotebookHandler("test.ipynb", JSON.stringify(notebook), securityConfig)
+ expect(handler.getCellByIndex(0)?.source).toBe("print('single line')")
+
+ // Update should preserve the format
+ handler.updateCell(0, "print('updated')")
+ expect(handler.getCellByIndex(0)?.source).toBe("print('updated')")
+ })
+
+ it("should handle cells with empty source", () => {
+ const notebook = {
+ cells: [
+ {
+ cell_type: "code" as const,
+ source: [],
+ metadata: {},
+ },
+ ],
+ }
+
+ const securityConfig = {
+ readOnlyMode: false,
+ enableWarnings: false,
+ }
+ const handler = new JupyterNotebookHandler("test.ipynb", JSON.stringify(notebook), securityConfig)
+ const text = handler.extractTextWithCellMarkers()
+ expect(text).toContain("# %%% Cell 1 [code]")
+ })
+ })
+
+ describe("Security Integration", () => {
+ it("should enforce read-only mode for dangerous notebooks", () => {
+ const dangerousNotebook = {
+ cells: [
+ {
+ cell_type: "code" as const,
+ source: ["import os\n", "os.system('rm -rf /')"],
+ metadata: {},
+ },
+ ],
+ }
+
+ const securityConfig = {
+ readOnlyMode: true,
+ enableWarnings: false,
+ }
+ const secureHandler = new JupyterNotebookHandler(
+ "dangerous.ipynb",
+ JSON.stringify(dangerousNotebook),
+ securityConfig,
+ )
+
+ expect(secureHandler.isInReadOnlyMode()).toBe(true)
+
+ // Should not allow updates in read-only mode
+ const success = secureHandler.updateCell(0, "print('safe')")
+ expect(success).toBe(false)
+ })
+
+ it("should get security recommendations", () => {
+ const recommendations = handler.getSecurityRecommendations()
+ expect(Array.isArray(recommendations)).toBe(true)
+ })
+
+ it("should check if operations are allowed", () => {
+ expect(handler.wouldAllowOperation("read")).toBe(true)
+ // Write is allowed because we marked test.ipynb as trusted
+ expect(handler.wouldAllowOperation("write")).toBe(true)
+ expect(handler.wouldAllowOperation("execute")).toBe(false) // Default config disables execution
+ })
+
+ it("should update security configuration", () => {
+ handler.updateSecurityConfig({ allowCodeExecution: true })
+ expect(handler.wouldAllowOperation("execute")).toBe(true)
+ })
+
+ it("should get sanitized notebook", () => {
+ const dangerousNotebook = {
+ cells: [
+ {
+ cell_type: "code" as const,
+ source: "import os\nos.system('dangerous')",
+ metadata: {},
+ outputs: [{ data: { "text/plain": "output" } }],
+ },
+ ],
+ }
+
+ const handler = new JupyterNotebookHandler("dangerous.ipynb", JSON.stringify(dangerousNotebook), {
+ readOnlyMode: false,
+ enableWarnings: false,
+ })
+
+ const sanitized = handler.getSanitizedNotebook()
+ const cell = sanitized.cells[0]
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source
+
+ // Should contain warning
+ expect(source).toContain("SECURITY WARNING")
+ })
+ })
+})
diff --git a/src/integrations/misc/__tests__/jupyter-notebook-security.spec.ts b/src/integrations/misc/__tests__/jupyter-notebook-security.spec.ts
new file mode 100644
index 0000000000..fc8e6d1a17
--- /dev/null
+++ b/src/integrations/misc/__tests__/jupyter-notebook-security.spec.ts
@@ -0,0 +1,614 @@
+import { describe, it, expect, beforeEach } from "vitest"
+import {
+ JupyterNotebookSecurity,
+ SecurityConfig,
+ SecurityUtils,
+ createDefaultSecurity,
+} from "../jupyter-notebook-security"
+import { JupyterNotebook, JupyterCell } from "../jupyter-notebook-handler"
+
+describe("JupyterNotebookSecurity", () => {
+ let security: JupyterNotebookSecurity
+ let defaultConfig: SecurityConfig
+
+ beforeEach(() => {
+ defaultConfig = {
+ allowCodeExecution: false,
+ readOnlyMode: true,
+ maxCellSize: 1000,
+ maxCellCount: 10,
+ allowDangerousImports: false,
+ enableWarnings: true,
+ }
+ security = new JupyterNotebookSecurity(defaultConfig)
+ })
+
+ describe("Code Cell Analysis", () => {
+ it("should detect eval/exec usage", () => {
+ const risks = security.analyzeCodeCell("eval('print(1)')")
+ // May detect multiple risks (eval pattern and blocked pattern)
+ const evalRisks = risks.filter((r) => r.type === "eval")
+ expect(evalRisks.length).toBeGreaterThan(0)
+ expect(evalRisks[0].type).toBe("eval")
+ expect(evalRisks[0].severity).toBe("critical")
+ })
+
+ it("should detect dangerous imports", () => {
+ const code = `
+import subprocess
+import os
+from socket import *
+import pickle
+ `
+ const risks = security.analyzeCodeCell(code)
+ const importRisks = risks.filter((r) => r.type === "import")
+ expect(importRisks.length).toBeGreaterThan(0)
+ expect(importRisks.some((r) => r.pattern === "subprocess")).toBe(true)
+ expect(importRisks.some((r) => r.pattern === "os")).toBe(true)
+ })
+
+ it("should detect system command execution", () => {
+ const risks1 = security.analyzeCodeCell("!ls -la")
+ expect(risks1.some((r) => r.type === "system_command")).toBe(true)
+
+ const risks2 = security.analyzeCodeCell("%system pwd")
+ expect(risks2.some((r) => r.type === "system_command")).toBe(true)
+ })
+
+ it("should detect file system access", () => {
+ const code = `
+with open('file.txt', 'r') as f:
+ content = f.read()
+ `
+ const risks = security.analyzeCodeCell(code)
+ expect(risks.some((r) => r.type === "file_access")).toBe(true)
+ })
+
+ it("should detect network operations", () => {
+ const code = `
+import urllib.request
+response = urllib.request.urlopen('http://example.com')
+ `
+ const risks = security.analyzeCodeCell(code)
+ expect(risks.some((r) => r.type === "network")).toBe(true)
+ })
+
+ it("should detect subprocess usage", () => {
+ const code = `
+import subprocess
+result = subprocess.run(['ls', '-l'], capture_output=True)
+ `
+ const risks = security.analyzeCodeCell(code)
+ expect(risks.some((r) => r.severity === "high")).toBe(true)
+ })
+
+ it("should allow safe code", () => {
+ const code = `
+import math
+import json
+from datetime import datetime
+
+def calculate(x, y):
+ return math.sqrt(x**2 + y**2)
+
+result = calculate(3, 4)
+print(f"Result: {result}")
+ `
+ const risks = security.analyzeCodeCell(code)
+ // Should only have warnings about imports, not critical/high risks
+ const highRisks = risks.filter((r) => r.severity === "high" || r.severity === "critical")
+ expect(highRisks).toHaveLength(0)
+ })
+ })
+
+ describe("Markdown Cell Analysis", () => {
+ it("should detect embedded JavaScript", () => {
+ const content = `
+# Title
+
+Some text
+ `
+ const risks = security.analyzeMarkdownCell(content)
+ expect(risks.some((r) => r.type === "code_execution")).toBe(true)
+ })
+
+ it("should detect iframes", () => {
+ const content = `
+
+ `
+ const risks = security.analyzeMarkdownCell(content)
+ expect(risks.some((r) => r.type === "network")).toBe(true)
+ })
+
+ it("should detect data URIs with scripts", () => {
+ const content = `
+
+ `
+ const risks = security.analyzeMarkdownCell(content)
+ expect(risks.some((r) => r.type === "code_execution")).toBe(true)
+ })
+
+ it("should allow safe markdown", () => {
+ const content = `
+# Safe Markdown
+This is **bold** and *italic* text.
+- List item 1
+- List item 2
+
+[Link](https://example.com)
+
+ `
+ const risks = security.analyzeMarkdownCell(content)
+ expect(risks).toHaveLength(0)
+ })
+ })
+
+ describe("Notebook Validation", () => {
+ it("should validate cell count", () => {
+ const notebook: JupyterNotebook = {
+ cells: Array(15).fill({
+ cell_type: "code",
+ source: "print('test')",
+ metadata: {},
+ }),
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.isValid).toBe(false)
+ expect(result.errors.some((e) => e.includes("exceeds maximum cell count"))).toBe(true)
+ })
+
+ it("should validate cell size", () => {
+ const largeContent = "x".repeat(1500)
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "code",
+ source: largeContent,
+ metadata: {},
+ },
+ ],
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.isValid).toBe(false)
+ expect(result.errors.some((e) => e.includes("exceeds maximum size"))).toBe(true)
+ })
+
+ it("should detect dangerous code in cells", () => {
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "code",
+ source: "import os\nos.system('rm -rf /')",
+ metadata: {},
+ },
+ ],
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.isValid).toBe(false)
+ expect(result.errors.length).toBeGreaterThan(0)
+ })
+
+ it("should validate clean notebook", () => {
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "markdown",
+ source: "# Clean Notebook",
+ metadata: {},
+ },
+ {
+ cell_type: "code",
+ source: "print('Hello, World!')",
+ metadata: {},
+ },
+ ],
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.isValid).toBe(true)
+ expect(result.errors).toHaveLength(0)
+ })
+
+ it("should bypass validation for trusted sources", () => {
+ const trustedSecurity = new JupyterNotebookSecurity({
+ ...defaultConfig,
+ trustedSources: ["/trusted/path"],
+ })
+
+ const dangerousNotebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "code",
+ source: "import os\nos.system('dangerous')",
+ metadata: {},
+ },
+ ],
+ }
+
+ const result = trustedSecurity.validateNotebook(dangerousNotebook, "/trusted/path/notebook.ipynb")
+ expect(result.isValid).toBe(true)
+ expect(result.warnings.some((w) => w.includes("trusted source"))).toBe(true)
+ })
+ })
+
+ describe("Cell Sanitization", () => {
+ it("should sanitize dangerous code cells", () => {
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "code",
+ source: "import os\nos.system('rm -rf /')",
+ metadata: {},
+ outputs: [{ data: { "text/plain": "output" } }],
+ execution_count: 1,
+ },
+ ],
+ }
+
+ const sanitized = security.sanitizeNotebook(notebook)
+ const cell = sanitized.cells[0] as JupyterCell
+
+ // Should add warning comment
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source
+ expect(source).toContain("SECURITY WARNING")
+
+ // Should clear outputs
+ expect(cell.outputs).toEqual([])
+ expect(cell.execution_count).toBeNull()
+ })
+
+ it("should sanitize markdown cells with scripts", () => {
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "markdown",
+ source: "# Title\n\nText",
+ metadata: {},
+ },
+ ],
+ }
+
+ const sanitized = security.sanitizeNotebook(notebook)
+ const cell = sanitized.cells[0]
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source
+
+ expect(source).not.toContain("")).toBe(true)
+ expect(SecurityUtils.hasCodeInjection("onclick='doSomething()'")).toBe(true)
+ expect(SecurityUtils.hasCodeInjection("javascript:void(0)")).toBe(true)
+ expect(SecurityUtils.hasCodeInjection("print('safe')")).toBe(false)
+ })
+
+ it("should get risk level from severity", () => {
+ expect(SecurityUtils.getRiskLevel("low")).toBe(1)
+ expect(SecurityUtils.getRiskLevel("medium")).toBe(2)
+ expect(SecurityUtils.getRiskLevel("high")).toBe(3)
+ expect(SecurityUtils.getRiskLevel("critical")).toBe(4)
+ })
+
+ it("should format security report", () => {
+ const validation = {
+ isValid: false,
+ errors: ["Error 1", "Error 2"],
+ warnings: ["Warning 1"],
+ }
+
+ const report = SecurityUtils.formatSecurityReport(validation)
+
+ expect(report).toContain("❌ INVALID")
+ expect(report).toContain("Error 1")
+ expect(report).toContain("Error 2")
+ expect(report).toContain("Warning 1")
+ })
+ })
+
+ describe("Output Validation", () => {
+ it("should validate output types", () => {
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "code",
+ source: "print('test')",
+ metadata: {},
+ outputs: [
+ {
+ data: {
+ "text/plain": "output",
+ "application/x-custom": "custom",
+ },
+ },
+ ],
+ },
+ ],
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.warnings.some((w) => w.includes("Unrecognized output type"))).toBe(true)
+ })
+
+ it("should detect JavaScript in HTML outputs", () => {
+ const notebook: JupyterNotebook = {
+ cells: [
+ {
+ cell_type: "code",
+ source: "display(HTML(''))",
+ metadata: {},
+ outputs: [
+ {
+ data: {
+ "text/html": "",
+ },
+ },
+ ],
+ },
+ ],
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.warnings.some((w) => w.includes("JavaScript detected in HTML output"))).toBe(true)
+ })
+ })
+
+ describe("Metadata Validation", () => {
+ it("should warn about non-Python kernels", () => {
+ const notebook: JupyterNotebook = {
+ cells: [],
+ metadata: {
+ kernelspec: {
+ language: "javascript",
+ name: "javascript",
+ },
+ },
+ }
+
+ const result = security.validateNotebook(notebook)
+ expect(result.warnings.some((w) => w.includes("Non-Python kernel"))).toBe(true)
+ })
+
+ it("should detect suspicious metadata keys", () => {
+ const notebook: JupyterNotebook = {
+ cells: [],
+ metadata: {
+ widgets: {},
+ extensions: {},
+ plugins: {},
+ hooks: {},
+ },
+ }
+
+ const result = security.validateNotebook(notebook)
+ const suspiciousWarnings = result.warnings.filter((w) => w.includes("suspicious metadata"))
+ expect(suspiciousWarnings.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe("Complex Attack Patterns", () => {
+ it("should detect obfuscated eval", () => {
+ const code = `
+e = chr(101) + chr(118) + chr(97) + chr(108)
+globals()[e]('print("hacked")')
+ `
+ const risks = security.analyzeCodeCell(code)
+ expect(risks.some((r) => r.type === "code_execution")).toBe(true)
+ })
+
+ it("should detect pickle deserialization", () => {
+ const code = `
+import pickle
+data = pickle.loads(untrusted_data)
+ `
+ const risks = security.analyzeCodeCell(code)
+ expect(risks.some((r) => r.severity === "high")).toBe(true)
+ })
+
+ it("should detect __import__ usage", () => {
+ const code = `
+module = __import__('os')
+module.system('ls')
+ `
+ const risks = security.analyzeCodeCell(code)
+ expect(risks.some((r) => r.severity === "high")).toBe(true)
+ })
+ })
+})
diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts
index 8231c609be..59869edb42 100644
--- a/src/integrations/misc/extract-text.ts
+++ b/src/integrations/misc/extract-text.ts
@@ -7,6 +7,7 @@ import { isBinaryFile } from "isbinaryfile"
import { extractTextFromXLSX } from "./extract-text-from-xlsx"
import { countFileLines } from "./line-counter"
import { readLines } from "./read-lines"
+import { JupyterNotebookHandler } from "./jupyter-notebook-handler"
async function extractTextFromPDF(filePath: string): Promise {
const dataBuffer = await fs.readFile(filePath)
@@ -20,17 +21,22 @@ async function extractTextFromDOCX(filePath: string): Promise {
}
async function extractTextFromIPYNB(filePath: string): Promise {
- const data = await fs.readFile(filePath, "utf8")
- const notebook = JSON.parse(data)
- let extractedText = ""
+ // Use secure configuration for text extraction
+ const securityConfig = {
+ readOnlyMode: true,
+ enableWarnings: true,
+ allowCodeExecution: false,
+ }
+ const handler = await JupyterNotebookHandler.fromFile(filePath, securityConfig)
- for (const cell of notebook.cells) {
- if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) {
- extractedText += cell.source.join("\n") + "\n"
- }
+ // Log security recommendations if there are any issues
+ const recommendations = handler.getSecurityRecommendations()
+ if (recommendations.length > 0 && !recommendations.some((r) => r.includes("✅"))) {
+ console.warn(`Security analysis for ${filePath}:`)
+ recommendations.forEach((rec) => console.warn(` ${rec}`))
}
- return addLineNumbers(extractedText)
+ return handler.extractTextWithCellMarkers()
}
/**
diff --git a/src/integrations/misc/jupyter-notebook-handler.ts b/src/integrations/misc/jupyter-notebook-handler.ts
new file mode 100644
index 0000000000..5bc6abe266
--- /dev/null
+++ b/src/integrations/misc/jupyter-notebook-handler.ts
@@ -0,0 +1,523 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+import { addLineNumbers } from "./extract-text"
+import {
+ JupyterNotebookSecurity,
+ SecurityConfig,
+ SecurityValidationResult,
+ createDefaultSecurity,
+} from "./jupyter-notebook-security"
+
+export interface JupyterCell {
+ cell_type: "code" | "markdown" | "raw"
+ source: string | string[]
+ metadata?: Record
+ outputs?: any[]
+ execution_count?: number | null
+}
+
+export interface JupyterNotebook {
+ cells: JupyterCell[]
+ metadata?: Record
+ nbformat?: number
+ nbformat_minor?: number
+}
+
+export interface CellReference {
+ index: number
+ type: "code" | "markdown" | "raw"
+ content: string
+ lineStart: number
+ lineEnd: number
+}
+
+export class JupyterNotebookHandler {
+ private notebook: JupyterNotebook
+ private filePath: string
+ private cellReferences: CellReference[] = []
+ private security: JupyterNotebookSecurity
+ private isReadOnly: boolean = false
+ private validationResult?: SecurityValidationResult
+
+ constructor(filePath: string, notebookContent?: string, securityConfig?: SecurityConfig) {
+ this.filePath = filePath
+ this.security = createDefaultSecurity(securityConfig)
+
+ if (notebookContent) {
+ this.notebook = JSON.parse(notebookContent)
+
+ // Validate notebook on load
+ this.validationResult = this.security.validateNotebook(this.notebook, filePath)
+
+ // If notebook has security issues and we're in read-only mode, use sanitized version
+ if (!this.validationResult.isValid && this.security.getConfig().readOnlyMode) {
+ if (this.validationResult.sanitized) {
+ this.notebook = this.validationResult.sanitized
+ this.isReadOnly = true
+ }
+ }
+
+ // Log security warnings if enabled
+ if (this.security.getConfig().enableWarnings) {
+ this.logSecurityWarnings()
+ }
+
+ this.buildCellReferences()
+ } else {
+ this.notebook = { cells: [] }
+ }
+ }
+
+ /**
+ * Load a Jupyter notebook from file
+ */
+ static async fromFile(filePath: string, securityConfig?: SecurityConfig): Promise {
+ const content = await fs.readFile(filePath, "utf8")
+ return new JupyterNotebookHandler(filePath, content, securityConfig)
+ }
+
+ /**
+ * Log security warnings to console
+ */
+ private logSecurityWarnings(): void {
+ if (!this.validationResult) return
+
+ if (this.validationResult.errors.length > 0) {
+ console.error("🔴 Jupyter Notebook Security Errors:")
+ this.validationResult.errors.forEach((error) => console.error(` - ${error}`))
+ }
+
+ if (this.validationResult.warnings.length > 0) {
+ console.warn("⚠️ Jupyter Notebook Security Warnings:")
+ this.validationResult.warnings.forEach((warning) => console.warn(` - ${warning}`))
+ }
+
+ if (this.isReadOnly) {
+ console.warn("📝 Notebook opened in READ-ONLY mode due to security concerns")
+ }
+ }
+
+ /**
+ * Check if the notebook is in read-only mode
+ */
+ public isInReadOnlyMode(): boolean {
+ return this.isReadOnly
+ }
+
+ /**
+ * Get security validation result
+ */
+ public getSecurityValidation(): SecurityValidationResult | undefined {
+ return this.validationResult
+ }
+
+ /**
+ * Get security recommendations for the notebook
+ */
+ public getSecurityRecommendations(): string[] {
+ return this.security.getSecurityRecommendations(this.notebook)
+ }
+
+ /**
+ * Build cell references with line number mappings
+ */
+ private buildCellReferences(): void {
+ this.cellReferences = []
+ let currentLine = 1
+
+ this.notebook.cells.forEach((cell, index) => {
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || ""
+ const lines = source.split("\n")
+ const lineCount = lines.length
+
+ this.cellReferences.push({
+ index,
+ type: cell.cell_type,
+ content: source,
+ lineStart: currentLine,
+ lineEnd: currentLine + lineCount - 1,
+ })
+
+ currentLine += lineCount + 1 // Add 1 for cell separator
+ })
+ }
+
+ /**
+ * Get cell at a specific line number
+ */
+ getCellAtLine(lineNumber: number): CellReference | undefined {
+ return this.cellReferences.find((ref) => lineNumber >= ref.lineStart && lineNumber <= ref.lineEnd)
+ }
+
+ /**
+ * Get cell by index
+ */
+ getCellByIndex(index: number): JupyterCell | undefined {
+ return this.notebook.cells[index]
+ }
+
+ /**
+ * Extract text with cell markers for better readability
+ */
+ extractTextWithCellMarkers(): string {
+ let result = ""
+ let lineNumber = 1
+
+ this.notebook.cells.forEach((cell, index) => {
+ const cellType = cell.cell_type
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || ""
+
+ // Add cell header
+ result += `# %%% Cell ${index + 1} [${cellType}]\n`
+
+ // Add cell content with line numbers
+ const lines = source.split("\n")
+ lines.forEach((line) => {
+ result += `${String(lineNumber).padStart(4, " ")} | ${line}\n`
+ lineNumber++
+ })
+
+ // Add cell separator
+ result += "\n"
+ lineNumber++ // Account for the separator line
+ })
+
+ return result
+ }
+
+ /**
+ * Extract text from specific cells
+ */
+ extractCellsText(cellIndices?: number[]): string {
+ let result = ""
+ const cellsToExtract = cellIndices
+ ? this.notebook.cells.filter((_, index) => cellIndices.includes(index))
+ : this.notebook.cells
+
+ cellsToExtract.forEach((cell, idx) => {
+ const actualIndex = cellIndices ? cellIndices[idx] : idx
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || ""
+
+ result += `# Cell ${actualIndex + 1} [${cell.cell_type}]\n`
+ result += source
+ result += "\n\n"
+ })
+
+ return result
+ }
+
+ /**
+ * Update a specific cell's content
+ */
+ updateCell(cellIndex: number, newContent: string): boolean {
+ // Check if operation is allowed
+ if (this.isReadOnly) {
+ console.error("Cannot update cell: Notebook is in read-only mode")
+ return false
+ }
+
+ if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) {
+ console.error("Cannot update cell: Security policy prevents write operations")
+ return false
+ }
+
+ if (cellIndex < 0 || cellIndex >= this.notebook.cells.length) {
+ return false
+ }
+
+ // Validate the new content for security risks
+ const tempCell = { ...this.notebook.cells[cellIndex] }
+ tempCell.source = newContent
+ const validation = this.security.validateCell(tempCell, cellIndex)
+
+ if (validation.errors.length > 0) {
+ console.error("Cannot update cell due to security errors:", validation.errors)
+ return false
+ }
+
+ if (validation.warnings.length > 0) {
+ console.warn("Security warnings for cell update:", validation.warnings)
+ }
+
+ const cell = this.notebook.cells[cellIndex]
+ // Preserve the original format (array vs string)
+ if (Array.isArray(cell.source)) {
+ // Split content and ensure each line ends with \n except the last
+ const lines = newContent.split("\n")
+ cell.source = lines.map((line, idx) => (idx === lines.length - 1 && line === "" ? line : line + "\n"))
+ // Remove trailing empty string if it exists
+ if (cell.source[cell.source.length - 1] === "") {
+ cell.source.pop()
+ }
+ } else {
+ cell.source = newContent
+ }
+
+ // Rebuild references after update
+ this.buildCellReferences()
+ return true
+ }
+
+ /**
+ * Insert a new cell
+ */
+ insertCell(index: number, cellType: "code" | "markdown" | "raw", content: string): boolean {
+ // Check if operation is allowed
+ if (this.isReadOnly) {
+ console.error("Cannot insert cell: Notebook is in read-only mode")
+ return false
+ }
+
+ if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) {
+ console.error("Cannot insert cell: Security policy prevents write operations")
+ return false
+ }
+
+ if (index < 0 || index > this.notebook.cells.length) {
+ return false
+ }
+
+ // Check if we're exceeding max cell count
+ const maxCellCount = this.security.getConfig().maxCellCount
+ if (this.notebook.cells.length >= maxCellCount) {
+ console.error(`Cannot insert cell: Maximum cell count (${maxCellCount}) reached`)
+ return false
+ }
+
+ const newCell: JupyterCell = {
+ cell_type: cellType,
+ source: content
+ .split("\n")
+ .map((line, idx, arr) => (idx === arr.length - 1 && line === "" ? line : line + "\n")),
+ metadata: {},
+ }
+
+ if (cellType === "code") {
+ newCell.outputs = []
+ newCell.execution_count = null
+ }
+
+ // Validate the new cell for security risks
+ const validation = this.security.validateCell(newCell, index)
+
+ if (validation.errors.length > 0) {
+ console.error("Cannot insert cell due to security errors:", validation.errors)
+ return false
+ }
+
+ if (validation.warnings.length > 0) {
+ console.warn("Security warnings for new cell:", validation.warnings)
+ }
+
+ this.notebook.cells.splice(index, 0, newCell)
+ this.buildCellReferences()
+ return true
+ }
+
+ /**
+ * Delete a cell
+ */
+ deleteCell(index: number): boolean {
+ // Check if operation is allowed
+ if (this.isReadOnly) {
+ console.error("Cannot delete cell: Notebook is in read-only mode")
+ return false
+ }
+
+ if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) {
+ console.error("Cannot delete cell: Security policy prevents write operations")
+ return false
+ }
+
+ if (index < 0 || index >= this.notebook.cells.length) {
+ return false
+ }
+
+ this.notebook.cells.splice(index, 1)
+ this.buildCellReferences()
+ return true
+ }
+
+ /**
+ * Apply a diff to a specific cell
+ */
+ applyCellDiff(cellIndex: number, searchContent: string, replaceContent: string): boolean {
+ const cell = this.getCellByIndex(cellIndex)
+ if (!cell) {
+ return false
+ }
+
+ const currentContent = Array.isArray(cell.source) ? cell.source.join("") : cell.source || ""
+
+ // Simple exact match replacement for now
+ if (currentContent.includes(searchContent)) {
+ const newContent = currentContent.replace(searchContent, replaceContent)
+ return this.updateCell(cellIndex, newContent)
+ }
+
+ return false
+ }
+
+ /**
+ * Save the notebook back to file
+ */
+ async save(): Promise {
+ // Check if operation is allowed
+ if (this.isReadOnly) {
+ throw new Error("Cannot save: Notebook is in read-only mode")
+ }
+
+ if (!this.security.shouldAllowOperation("write", this.notebook, this.filePath)) {
+ throw new Error("Cannot save: Security policy prevents write operations")
+ }
+
+ // Validate entire notebook before saving
+ const validation = this.security.validateNotebook(this.notebook, this.filePath)
+
+ if (!validation.isValid && validation.errors.length > 0) {
+ throw new Error(`Cannot save notebook with security errors: ${validation.errors.join(", ")}`)
+ }
+
+ const content = JSON.stringify(this.notebook, null, 2)
+ await fs.writeFile(this.filePath, content, "utf8")
+ }
+
+ /**
+ * Get a sanitized version of the notebook
+ */
+ public getSanitizedNotebook(): JupyterNotebook {
+ return this.security.sanitizeNotebook(this.notebook)
+ }
+
+ /**
+ * Update security configuration
+ */
+ public updateSecurityConfig(config: Partial): void {
+ this.security.updateConfig(config)
+ // Re-validate with new config
+ this.validationResult = this.security.validateNotebook(this.notebook, this.filePath)
+
+ if (!this.validationResult.isValid && this.security.getConfig().readOnlyMode) {
+ this.isReadOnly = true
+ if (this.validationResult.sanitized) {
+ this.notebook = this.validationResult.sanitized
+ this.buildCellReferences()
+ }
+ } else {
+ this.isReadOnly = false
+ }
+ }
+
+ /**
+ * Check if a specific operation would be allowed
+ */
+ public wouldAllowOperation(operation: "read" | "write" | "execute"): boolean {
+ return this.security.shouldAllowOperation(operation, this.notebook, this.filePath)
+ }
+
+ /**
+ * Get the notebook as JSON string
+ */
+ toJSON(): string {
+ return JSON.stringify(this.notebook, null, 2)
+ }
+
+ /**
+ * Get cell count
+ */
+ getCellCount(): number {
+ return this.notebook.cells.length
+ }
+
+ /**
+ * Get all cells of a specific type
+ */
+ getCellsByType(cellType: "code" | "markdown" | "raw"): Array<{ index: number; cell: JupyterCell }> {
+ return this.notebook.cells
+ .map((cell, index) => ({ index, cell }))
+ .filter(({ cell }) => cell.cell_type === cellType)
+ }
+
+ /**
+ * Search for content in cells
+ */
+ searchInCells(searchTerm: string): Array<{ cellIndex: number; matches: string[] }> {
+ const results: Array<{ cellIndex: number; matches: string[] }> = []
+
+ this.notebook.cells.forEach((cell, index) => {
+ const source = Array.isArray(cell.source) ? cell.source.join("") : cell.source || ""
+ const lines = source.split("\n")
+ const matches = lines.filter((line) => line.includes(searchTerm))
+
+ if (matches.length > 0) {
+ results.push({ cellIndex: index, matches })
+ }
+ })
+
+ return results
+ }
+
+ /**
+ * Create a checkpoint-friendly representation
+ */
+ getCheckpointRepresentation(): string {
+ return this.extractTextWithCellMarkers()
+ }
+
+ /**
+ * Restore from checkpoint representation
+ */
+ static fromCheckpointRepresentation(checkpointContent: string, originalNotebook: JupyterNotebook): JupyterNotebook {
+ // Parse the checkpoint content and reconstruct cells
+ const lines = checkpointContent.split("\n")
+ const cells: JupyterCell[] = []
+ let currentCell: JupyterCell | null = null
+ let currentContent: string[] = []
+
+ for (const line of lines) {
+ // Check for cell marker
+ const cellMarkerMatch = line.match(/^# %%% Cell (\d+) \[(code|markdown|raw)\]$/)
+ if (cellMarkerMatch) {
+ // Save previous cell if exists
+ if (currentCell) {
+ currentCell.source = currentContent.join("\n")
+ cells.push(currentCell)
+ }
+
+ // Start new cell
+ const cellIndex = parseInt(cellMarkerMatch[1]) - 1
+ const cellType = cellMarkerMatch[2] as "code" | "markdown" | "raw"
+
+ // Try to preserve metadata from original
+ const originalCell = originalNotebook.cells[cellIndex]
+ currentCell = {
+ cell_type: cellType,
+ source: "",
+ metadata: originalCell?.metadata || {},
+ }
+
+ if (cellType === "code") {
+ currentCell.outputs = originalCell?.outputs || []
+ currentCell.execution_count = originalCell?.execution_count || null
+ }
+
+ currentContent = []
+ } else if (line.match(/^\s*\d+\s*\|/)) {
+ // Extract content from numbered line
+ const content = line.replace(/^\s*\d+\s*\|\s?/, "")
+ currentContent.push(content)
+ }
+ }
+
+ // Save last cell
+ if (currentCell) {
+ currentCell.source = currentContent.join("\n")
+ cells.push(currentCell)
+ }
+
+ return {
+ ...originalNotebook,
+ cells,
+ }
+ }
+}
diff --git a/src/integrations/misc/jupyter-notebook-security.ts b/src/integrations/misc/jupyter-notebook-security.ts
new file mode 100644
index 0000000000..390da94da5
--- /dev/null
+++ b/src/integrations/misc/jupyter-notebook-security.ts
@@ -0,0 +1,658 @@
+/**
+ * Security module for Jupyter notebook handling
+ * Provides validation, sanitization, and security controls for notebook operations
+ */
+
+import { JupyterCell, JupyterNotebook } from "./jupyter-notebook-handler"
+
+export interface SecurityConfig {
+ /** Allow execution of code cells (default: false) */
+ allowCodeExecution?: boolean
+ /** Enable read-only mode for untrusted notebooks (default: true) */
+ readOnlyMode?: boolean
+ /** Maximum allowed cell size in characters (default: 1MB) */
+ maxCellSize?: number
+ /** Maximum number of cells allowed (default: 1000) */
+ maxCellCount?: number
+ /** Allow potentially dangerous imports (default: false) */
+ allowDangerousImports?: boolean
+ /** List of blocked patterns in code cells */
+ blockedPatterns?: RegExp[]
+ /** List of allowed file extensions for outputs */
+ allowedOutputTypes?: string[]
+ /** Enable security warnings (default: true) */
+ enableWarnings?: boolean
+ /** Trusted notebook sources (file paths or patterns) */
+ trustedSources?: string[]
+}
+
+export interface SecurityValidationResult {
+ isValid: boolean
+ errors: string[]
+ warnings: string[]
+ sanitized?: JupyterNotebook
+}
+
+export interface CellSecurityInfo {
+ cellIndex: number
+ cellType: string
+ risks: SecurityRisk[]
+ isSafe: boolean
+}
+
+export interface SecurityRisk {
+ type: "code_execution" | "import" | "file_access" | "network" | "system_command" | "eval" | "size_limit"
+ severity: "low" | "medium" | "high" | "critical"
+ description: string
+ pattern?: string
+}
+
+const DEFAULT_CONFIG: Required = {
+ allowCodeExecution: false,
+ readOnlyMode: true,
+ maxCellSize: 1024 * 1024, // 1MB
+ maxCellCount: 1000,
+ allowDangerousImports: false,
+ blockedPatterns: [
+ // System commands and shell execution
+ /\b(exec|eval|compile|__import__|open|subprocess|os\.system|os\.popen|commands\.)/gi,
+ // File system operations
+ /\b(shutil\.|pathlib\.|glob\.|tempfile\.|zipfile\.|tarfile\.)/gi,
+ // Network operations
+ /\b(urllib\.|requests\.|socket\.|http\.|ftplib\.|telnetlib\.|smtplib\.)/gi,
+ // Dangerous built-ins
+ /\b(globals|locals|vars|dir|getattr|setattr|delattr|hasattr)\s*\(/gi,
+ // Code injection patterns
+ /\b(pickle\.|marshal\.|shelve\.|dill\.)/gi,
+ // Process and thread manipulation
+ /\b(multiprocessing\.|threading\.|concurrent\.|asyncio\.)/gi,
+ ],
+ allowedOutputTypes: ["text/plain", "text/html", "image/png", "image/jpeg", "image/svg+xml"],
+ enableWarnings: true,
+ trustedSources: [],
+}
+
+const DANGEROUS_IMPORTS = [
+ "subprocess",
+ "os",
+ "sys",
+ "socket",
+ "urllib",
+ "requests",
+ "pickle",
+ "marshal",
+ "shelve",
+ "dill",
+ "multiprocessing",
+ "threading",
+ "ctypes",
+ "pty",
+ "fcntl",
+ "termios",
+ "tty",
+ "pwd",
+ "grp",
+ "resource",
+ "signal",
+ "syslog",
+ "tempfile",
+ "shutil",
+ "glob",
+ "pathlib",
+ "zipfile",
+ "tarfile",
+ "gzip",
+ "bz2",
+ "lzma",
+ "sqlite3",
+ "psycopg2",
+ "pymongo",
+ "redis",
+ "paramiko",
+ "fabric",
+ "ansible",
+ "docker",
+ "kubernetes",
+]
+
+export class JupyterNotebookSecurity {
+ private config: Required
+
+ constructor(config?: SecurityConfig) {
+ this.config = { ...DEFAULT_CONFIG, ...config }
+ }
+
+ /**
+ * Validate a Jupyter notebook for security risks
+ */
+ public validateNotebook(notebook: JupyterNotebook, sourcePath?: string): SecurityValidationResult {
+ const errors: string[] = []
+ const warnings: string[] = []
+
+ // Check if source is trusted
+ if (sourcePath && this.isSourceTrusted(sourcePath)) {
+ return {
+ isValid: true,
+ errors: [],
+ warnings: ["Notebook from trusted source - security checks bypassed"],
+ }
+ }
+
+ // Check cell count
+ if (notebook.cells.length > this.config.maxCellCount) {
+ errors.push(`Notebook exceeds maximum cell count (${notebook.cells.length} > ${this.config.maxCellCount})`)
+ }
+
+ // Validate each cell
+ notebook.cells.forEach((cell, index) => {
+ const cellValidation = this.validateCell(cell, index)
+ errors.push(...cellValidation.errors)
+ warnings.push(...cellValidation.warnings)
+ })
+
+ // Check for suspicious metadata
+ if (notebook.metadata) {
+ const metadataWarnings = this.validateMetadata(notebook.metadata)
+ warnings.push(...metadataWarnings)
+ }
+
+ const isValid = errors.length === 0
+
+ // Sanitize if needed
+ let sanitized: JupyterNotebook | undefined
+ if (!isValid && this.config.readOnlyMode) {
+ sanitized = this.sanitizeNotebook(notebook)
+ }
+
+ return {
+ isValid,
+ errors,
+ warnings,
+ sanitized,
+ }
+ }
+
+ /**
+ * Validate a single cell for security risks
+ */
+ public validateCell(cell: JupyterCell, index: number): { errors: string[]; warnings: string[] } {
+ const errors: string[] = []
+ const warnings: string[] = []
+
+ // Get cell content as string
+ const content = Array.isArray(cell.source) ? cell.source.join("") : cell.source || ""
+
+ // Check cell size
+ if (content.length > this.config.maxCellSize) {
+ errors.push(
+ `Cell ${index} exceeds maximum size (${content.length} > ${this.config.maxCellSize} characters)`,
+ )
+ }
+
+ // Check code cells for dangerous patterns
+ if (cell.cell_type === "code") {
+ const codeRisks = this.analyzeCodeCell(content)
+
+ codeRisks.forEach((risk) => {
+ const message = `Cell ${index}: ${risk.description}`
+ if (risk.severity === "critical" || risk.severity === "high") {
+ errors.push(message)
+ } else {
+ warnings.push(message)
+ }
+ })
+
+ // Check outputs for suspicious content
+ if (cell.outputs && Array.isArray(cell.outputs)) {
+ const outputWarnings = this.validateOutputs(cell.outputs, index)
+ warnings.push(...outputWarnings)
+ }
+ }
+
+ // Check for embedded scripts in markdown cells
+ if (cell.cell_type === "markdown") {
+ const markdownRisks = this.analyzeMarkdownCell(content)
+ markdownRisks.forEach((risk) => {
+ warnings.push(`Cell ${index}: ${risk.description}`)
+ })
+ }
+
+ return { errors, warnings }
+ }
+
+ /**
+ * Analyze a code cell for security risks
+ */
+ public analyzeCodeCell(content: string): SecurityRisk[] {
+ const risks: SecurityRisk[] = []
+
+ // Check for blocked patterns
+ this.config.blockedPatterns.forEach((pattern) => {
+ if (pattern.test(content)) {
+ risks.push({
+ type: "code_execution",
+ severity: "high",
+ description: `Potentially dangerous code pattern detected: ${pattern.source}`,
+ pattern: pattern.source,
+ })
+ }
+ })
+
+ // Check for dangerous imports
+ if (!this.config.allowDangerousImports) {
+ const importRegex = /(?:from\s+(\S+)\s+import|import\s+(\S+))/g
+ let match
+ while ((match = importRegex.exec(content)) !== null) {
+ const module = (match[1] || match[2]).split(".")[0].replace(",", "")
+ if (DANGEROUS_IMPORTS.includes(module)) {
+ risks.push({
+ type: "import",
+ severity: "high",
+ description: `Dangerous import detected: ${module}`,
+ pattern: module,
+ })
+ }
+ }
+ }
+
+ // Check for eval/exec usage
+ if (/\b(eval|exec|compile)\s*\(/.test(content)) {
+ risks.push({
+ type: "eval",
+ severity: "critical",
+ description: "Dynamic code execution detected (eval/exec/compile)",
+ })
+ }
+
+ // Check for file system access
+ if (/\b(open|read|write)\s*\(/.test(content)) {
+ risks.push({
+ type: "file_access",
+ severity: "medium",
+ description: "File system access detected",
+ })
+ }
+
+ // Check for network operations
+ if (/\b(urlopen|urlretrieve|get|post|put|delete)\s*\(/.test(content)) {
+ risks.push({
+ type: "network",
+ severity: "medium",
+ description: "Network operation detected",
+ })
+ }
+
+ // Check for system commands
+ if (/!\s*[a-zA-Z]/.test(content) || /%\s*system/.test(content)) {
+ risks.push({
+ type: "system_command",
+ severity: "critical",
+ description: "System command execution detected",
+ })
+ }
+
+ return risks
+ }
+
+ /**
+ * Analyze a markdown cell for security risks
+ */
+ public analyzeMarkdownCell(content: string): SecurityRisk[] {
+ const risks: SecurityRisk[] = []
+
+ // Check for embedded JavaScript
+ if (/