@privacyscrubber/mcp-server 2.4.4 → 2.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -82,6 +82,9 @@ if (rawCliArgs.includes('--memo') || rawCliArgs.includes('memo')) {
82
82
  console.log(generateProcurementMemo({ targetTier: tierArg, format: 'text' }));
83
83
  process.exit(0);
84
84
  }
85
+ if (rawCliArgs.includes('--hook')) {
86
+ await import('./pii-masking-run.js');
87
+ }
85
88
  if (rawCliArgs.some(arg => ['init', 'install', '--install', 'setup', '--setup', 'uninstall', '--uninstall', '--remove'].includes(arg))) {
86
89
  const { runInstaller } = await import('./install.js');
87
90
  const isRemove = rawCliArgs.some(arg => ['uninstall', '--uninstall', '--remove'].includes(arg));
@@ -1364,6 +1367,15 @@ We request approval to acquire a **PrivacyScrubber TEAMS License** (\$99/month f
1364
1367
  `;
1365
1368
  }
1366
1369
 
1370
+ const MCP_SYSTEM_INSTRUCTIONS = [
1371
+ "Zero-Trust Data Sanitization (ZTDS) Protocol Active (100% local RAM, 0 network egress).",
1372
+ "AUTOMATIC INTERCEPTION RULES FOR AI AGENTS:",
1373
+ "1. FILE READS: Before reading .env, configs, logs, database dumps, or documents (DOCX/PDF/XLSX/CSV/JSON), call 'guard_read_file' (or 'sanitize_file') instead of native file read tools.",
1374
+ "2. SHELL & GIT: Route terminal commands that may output secrets or PII through 'guard_exec', and inspect diffs via 'guard_git_diff'.",
1375
+ "3. RAW TEXT & RAG: Sanitize raw user PII/secrets in prompts via 'sanitize_text' (or 'guard_rag_chunk' for vector chunks) before external reasoning.",
1376
+ "4. LOCAL RESTORATION: Keep placeholders like [EMAIL_1], [API_KEY_1], [NAME_1] intact during reasoning. Call 'reveal_text' to restore final answers, 'guard_apply_patch' to write unmasked code/configs to disk, or 'guard_unmask_args' for local tool calls."
1377
+ ].join("\n");
1378
+
1367
1379
  // Create the MCP server
1368
1380
  const server = new Server(
1369
1381
  {
@@ -1377,6 +1389,7 @@ const server = new Server(
1377
1389
  logging: {},
1378
1390
  resources: {},
1379
1391
  },
1392
+ instructions: MCP_SYSTEM_INSTRUCTIONS,
1380
1393
  }
1381
1394
  );
1382
1395
 
@@ -1387,6 +1400,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1387
1400
  {
1388
1401
  name: "audit_context",
1389
1402
  description: "STEP 0 (Pre-Flight): Non-destructive security inspection of raw prompts or document chunks before sending to LLMs. Evaluates PII, secrets, risk severity (CLEAN, LOW, MODERATE, CRITICAL), and triggered regulatory frameworks (GDPR, HIPAA, SOC 2, PCI DSS) with zero text mutation.",
1403
+ annotations: {
1404
+ title: "Audit Context for PII & Secrets",
1405
+ readOnlyHint: true,
1406
+ destructiveHint: false,
1407
+ idempotentHint: true,
1408
+ openWorldHint: false
1409
+ },
1390
1410
  inputSchema: {
1391
1411
  type: "object",
1392
1412
  properties: {
@@ -1414,6 +1434,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1414
1434
  {
1415
1435
  name: "sanitize_text",
1416
1436
  description: "STEP 1: Call this first. You MUST NOT process raw user data before calling this. Locally scrubs PII, secrets, and credentials (like API keys, passwords, emails, phones, names) from code, logs, or text. Replaces them with safe placeholders (e.g., [EMAIL_1], [API_KEY_1]). Keep your data secure before passing it to any LLM. Supports General and all 30 specialized industry profiles in RAM session map. (For in-code backend services or RAG vector pipelines outside of MCP, use '@privacyscrubber/sdk': npm i @privacyscrubber/sdk)",
1437
+ annotations: {
1438
+ title: "Sanitize Text in Local RAM",
1439
+ readOnlyHint: true,
1440
+ destructiveHint: false,
1441
+ idempotentHint: true,
1442
+ openWorldHint: false
1443
+ },
1417
1444
  inputSchema: {
1418
1445
  type: "object",
1419
1446
  properties: {
@@ -1446,44 +1473,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1446
1473
  required: ["text"]
1447
1474
  }
1448
1475
  },
1449
- {
1450
- name: "scrub_text",
1451
- description: "Alias for 'sanitize_text'. Locally scrubs PII, secrets, and credentials from prompts or text before LLM ingestion. Supports General and all 30 specialized industry profiles in RAM session map.",
1452
- inputSchema: {
1453
- type: "object",
1454
- properties: {
1455
- text: {
1456
- type: "string",
1457
- description: "The raw text, code, or logs to sanitize."
1458
- },
1459
- profile: {
1460
- type: "string",
1461
- description: PROFILE_PARAM_DESCRIPTION
1462
- },
1463
- sector: {
1464
- type: "string",
1465
- description: SECTOR_PARAM_DESCRIPTION
1466
- },
1467
- compact: {
1468
- type: "boolean",
1469
- description: "Optional. Compact 1-line audit summary."
1470
- },
1471
- ignore_list: {
1472
- type: "array",
1473
- items: { type: "string" },
1474
- description: "Optional list of plaintext values to skip during detection."
1475
- },
1476
- session_id: {
1477
- type: "string",
1478
- description: "Optional session ID for multi-agent isolation. Defaults to 'default'."
1479
- }
1480
- },
1481
- required: ["text"]
1482
- }
1483
- },
1484
1476
  {
1485
1477
  name: "reveal_text",
1486
1478
  description: "STEP 3: Call this last. You MUST pass your final generated response through this tool to restore tokens (e.g., [EMAIL_1]) back with the original private data from the local volatile RAM-only session map before showing it to the user. Supports both standard batch mode and real-time streaming mode (stream: true).",
1479
+ annotations: {
1480
+ title: "Restore Tokens in Local RAM",
1481
+ readOnlyHint: true,
1482
+ destructiveHint: false,
1483
+ idempotentHint: true,
1484
+ openWorldHint: false
1485
+ },
1487
1486
  inputSchema: {
1488
1487
  type: "object",
1489
1488
  properties: {
@@ -1510,6 +1509,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1510
1509
  {
1511
1510
  name: "stream_reveal_text",
1512
1511
  description: "STEP 3 (Streaming): Real-time streaming token detokenizer for LLM Server-Sent Events (SSE) and stdio chunk pipelines. Buffers split token candidates across chunk boundaries (e.g. '[EM' + 'AIL_1]') so no characters or tokens are corrupted. Pass 'flush: true' on the final chunk.",
1512
+ annotations: {
1513
+ title: "Streaming Token Detokenizer",
1514
+ readOnlyHint: true,
1515
+ destructiveHint: false,
1516
+ idempotentHint: false,
1517
+ openWorldHint: false
1518
+ },
1513
1519
  inputSchema: {
1514
1520
  type: "object",
1515
1521
  properties: {
@@ -1535,43 +1541,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1535
1541
  },
1536
1542
  {
1537
1543
  name: "sanitize_file",
1538
- description: "Reads a local file, sanitizes its contents using the selected profile, and outputs the safe version for AI analysis. Securely keeps original identifiers in memory.",
1539
- inputSchema: {
1540
- type: "object",
1541
- properties: {
1542
- file_path: {
1543
- type: "string",
1544
- description: "Absolute path to the file to sanitize."
1545
- },
1546
- profile: {
1547
- type: "string",
1548
- description: PROFILE_PARAM_DESCRIPTION
1549
- },
1550
- sector: {
1551
- type: "string",
1552
- description: SECTOR_PARAM_DESCRIPTION
1553
- },
1554
- compact: {
1555
- type: "boolean",
1556
- description: "Optional. Compact 1-line audit summary."
1557
- },
1558
- session_id: {
1559
- type: "string",
1560
- description: "Optional session ID for multi-agent isolation. Defaults to 'default'."
1561
- }
1562
- },
1563
- required: ["file_path"]
1564
- }
1565
- },
1566
- {
1567
- name: "scrub_file",
1568
- description: "Alias for 'sanitize_file'. Reads and sanitizes a local file.",
1544
+ description: "Reads a local file (including plain text, source code, .eml, .rtf, .docx, .pdf, .xlsx), sanitizes its contents using the selected profile, and outputs the safe version for AI analysis. Securely keeps original identifiers in memory.",
1545
+ annotations: {
1546
+ title: "Sanitize Local File in RAM",
1547
+ readOnlyHint: true,
1548
+ destructiveHint: false,
1549
+ idempotentHint: true,
1550
+ openWorldHint: false
1551
+ },
1569
1552
  inputSchema: {
1570
1553
  type: "object",
1571
1554
  properties: {
1572
1555
  file_path: {
1573
1556
  type: "string",
1574
- description: "Absolute path to the file to sanitize."
1557
+ description: "Absolute or workspace-relative path to the file to sanitize."
1575
1558
  },
1576
1559
  profile: {
1577
1560
  type: "string",
@@ -1596,6 +1579,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1596
1579
  {
1597
1580
  name: "audit_directory_for_pii",
1598
1581
  description: "Scans a local directory for leaks of secrets, keys, and PII. Returns a summary report. Use this tool for Security Auditing.",
1582
+ annotations: {
1583
+ title: "Audit Directory for PII & Secrets",
1584
+ readOnlyHint: true,
1585
+ destructiveHint: false,
1586
+ idempotentHint: true,
1587
+ openWorldHint: false
1588
+ },
1599
1589
  inputSchema: {
1600
1590
  type: "object",
1601
1591
  properties: {
@@ -1627,6 +1617,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1627
1617
  {
1628
1618
  name: "redact_file",
1629
1619
  description: "Action/Redact: In-place redaction of a local file. Replaces PII and secrets with tokens and saves the file. By default, creates a .bak backup. Use dry_run=true to test without modifying.",
1620
+ annotations: {
1621
+ title: "In-Place File Redaction",
1622
+ readOnlyHint: false,
1623
+ destructiveHint: true,
1624
+ idempotentHint: false,
1625
+ openWorldHint: false
1626
+ },
1630
1627
  inputSchema: {
1631
1628
  type: "object",
1632
1629
  properties: {
@@ -1657,6 +1654,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1657
1654
  {
1658
1655
  name: "create_default_config",
1659
1656
  description: "Creates a default 'privacyscrubber.json' configuration file in the active workspace root directory if one does not exist. Includes template structures for custom regex rules and exclusion bypass patterns.",
1657
+ annotations: {
1658
+ title: "Create Default Workspace Config",
1659
+ readOnlyHint: false,
1660
+ destructiveHint: false,
1661
+ idempotentHint: true,
1662
+ openWorldHint: false
1663
+ },
1660
1664
  inputSchema: {
1661
1665
  type: "object",
1662
1666
  properties: {},
@@ -1666,6 +1670,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1666
1670
  {
1667
1671
  name: "generate_compliance_report",
1668
1672
  description: "Generates an official Zero-Trust Compliance Audit Certificate (GDPR, HIPAA, EU AI Act, SOC 2) for the current MCP session. Returns cryptographic session hash, masked entities breakdown, and compliance certification.",
1673
+ annotations: {
1674
+ title: "Generate Compliance Audit Certificate",
1675
+ readOnlyHint: true,
1676
+ destructiveHint: false,
1677
+ idempotentHint: true,
1678
+ openWorldHint: false
1679
+ },
1669
1680
  inputSchema: {
1670
1681
  type: "object",
1671
1682
  properties: {
@@ -1693,6 +1704,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1693
1704
  {
1694
1705
  name: "mark_false_positive",
1695
1706
  description: "Marks a previously detected token as a false positive. The original plaintext value will be excluded from all future sanitize_text calls in this session. Returns the restored original value and updated ignore list size.",
1707
+ annotations: {
1708
+ title: "Mark Token as False Positive",
1709
+ readOnlyHint: true,
1710
+ destructiveHint: false,
1711
+ idempotentHint: true,
1712
+ openWorldHint: false
1713
+ },
1696
1714
  inputSchema: {
1697
1715
  type: "object",
1698
1716
  properties: {
@@ -1711,6 +1729,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1711
1729
  {
1712
1730
  name: "check_status",
1713
1731
  description: "Returns the current PrivacyScrubber MCP tier, session usage, available profiles, and PRO upgrade instructions. Call this to see your license status or get setup help.",
1732
+ annotations: {
1733
+ title: "Check MCP License & Session Status",
1734
+ readOnlyHint: true,
1735
+ destructiveHint: false,
1736
+ idempotentHint: true,
1737
+ openWorldHint: false
1738
+ },
1714
1739
  inputSchema: {
1715
1740
  type: "object",
1716
1741
  properties: {},
@@ -1720,6 +1745,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1720
1745
  {
1721
1746
  name: "guard_exec",
1722
1747
  description: "Zero-Trust Agentic Guard: Safely executes a shell/terminal command locally in isolated process, sanitizes stdout and stderr in volatile RAM, tokenizes any API keys, credentials, database URIs, or PII before sending output to LLM. Maps original values in memory for safe local restoration.",
1748
+ annotations: {
1749
+ title: "Guard Shell Command Execution",
1750
+ readOnlyHint: false,
1751
+ destructiveHint: true,
1752
+ idempotentHint: false,
1753
+ openWorldHint: false
1754
+ },
1723
1755
  inputSchema: {
1724
1756
  type: "object",
1725
1757
  properties: {
@@ -1742,6 +1774,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1742
1774
  timeout_ms: {
1743
1775
  type: "number",
1744
1776
  description: "Execution timeout in milliseconds. Defaults to 15000 (15s)."
1777
+ },
1778
+ session_id: {
1779
+ type: "string",
1780
+ description: "Optional session ID for multi-agent isolation. Defaults to 'default'."
1745
1781
  }
1746
1782
  },
1747
1783
  required: ["command"]
@@ -1749,7 +1785,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1749
1785
  },
1750
1786
  {
1751
1787
  name: "guard_read_file",
1752
- description: "Zero-Trust Agentic Guard: Reads a sensitive local file (.env, config, credentials, source code, database dumps), sanitizes all credentials and PII in volatile RAM, and returns safe tokenized content for AI agent context.",
1788
+ description: "Zero-Trust Agentic Guard: Preferred over native file read tools. Reads any sensitive local file (.env, config, credentials, source code, database dumps, .docx, .pdf, .xlsx, .eml, .rtf), sanitizes all credentials and PII in volatile RAM, and returns safe tokenized content for AI agent context.",
1789
+ annotations: {
1790
+ title: "Guard Local File Read",
1791
+ readOnlyHint: true,
1792
+ destructiveHint: false,
1793
+ idempotentHint: true,
1794
+ openWorldHint: false
1795
+ },
1753
1796
  inputSchema: {
1754
1797
  type: "object",
1755
1798
  properties: {
@@ -1768,6 +1811,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1768
1811
  max_lines: {
1769
1812
  type: "number",
1770
1813
  description: "Optional line cap for large files. Defaults to 500."
1814
+ },
1815
+ session_id: {
1816
+ type: "string",
1817
+ description: "Optional session ID for multi-agent isolation. Defaults to 'default'."
1771
1818
  }
1772
1819
  },
1773
1820
  required: ["file_path"]
@@ -1776,6 +1823,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1776
1823
  {
1777
1824
  name: "guard_git_diff",
1778
1825
  description: "Zero-Trust Agentic Guard: Inspects unstaged or staged git diffs locally, sanitizes any newly added or modified credentials, tokens, or PII in RAM, and returns safe diff for AI commit generation or code review.",
1826
+ annotations: {
1827
+ title: "Guard Git Diff Inspection",
1828
+ readOnlyHint: true,
1829
+ destructiveHint: false,
1830
+ idempotentHint: true,
1831
+ openWorldHint: false
1832
+ },
1779
1833
  inputSchema: {
1780
1834
  type: "object",
1781
1835
  properties: {
@@ -1806,6 +1860,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1806
1860
  {
1807
1861
  name: "guard_apply_patch",
1808
1862
  description: "Zero-Trust Agentic Guard: Reverses token placeholders ([API_KEY_1], [SECRET_1]) in AI-generated code or text, restores authentic values from local volatile RAM, and writes real content directly to target file on disk. Remote LLM never learns actual secrets.",
1863
+ annotations: {
1864
+ title: "Guard Apply Unmasked Patch to Disk",
1865
+ readOnlyHint: false,
1866
+ destructiveHint: false,
1867
+ idempotentHint: true,
1868
+ openWorldHint: false
1869
+ },
1809
1870
  inputSchema: {
1810
1871
  type: "object",
1811
1872
  properties: {
@@ -1820,6 +1881,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1820
1881
  create_backup: {
1821
1882
  type: "boolean",
1822
1883
  description: "Whether to create a .bak backup if target file already exists. Defaults to true."
1884
+ },
1885
+ session_id: {
1886
+ type: "string",
1887
+ description: "Optional session ID for multi-agent isolation. Defaults to 'default'."
1823
1888
  }
1824
1889
  },
1825
1890
  required: ["file_path", "content"]
@@ -1827,7 +1892,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1827
1892
  },
1828
1893
  {
1829
1894
  name: "create_agent_rules",
1830
- description: "Zero-Trust Agentic Guard: Scaffolds CISO-grade AI Agent Rules in workspace (.cursorrules, .windsurfrules, CLAUDE.md, .github/copilot-instructions.md, .clinerules). Guides autonomous agents to route all file reads, git diffs, and shell commands through PrivacyScrubber Guard tools.",
1895
+ description: "Zero-Trust Agentic Guard: Scaffolds CISO-grade AI Agent Rules in workspace (.cursor/rules/privacyscrubber.mdc, .cursorrules, .windsurfrules, CLAUDE.md, AGENTS.md, .github/copilot-instructions.md, .clinerules). Guides autonomous agents to route all file reads, git diffs, and shell commands through PrivacyScrubber Guard tools.",
1896
+ annotations: {
1897
+ title: "Scaffold AI Agent Security Rules",
1898
+ readOnlyHint: false,
1899
+ destructiveHint: false,
1900
+ idempotentHint: true,
1901
+ openWorldHint: false
1902
+ },
1831
1903
  inputSchema: {
1832
1904
  type: "object",
1833
1905
  properties: {
@@ -1835,7 +1907,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1835
1907
  type: "array",
1836
1908
  items: {
1837
1909
  type: "string",
1838
- enum: ["all", "cursor", "windsurf", "claude_code", "copilot", "cline"]
1910
+ enum: ["all", "cursor", "windsurf", "claude_code", "agents_md", "codex", "copilot", "cline"]
1839
1911
  },
1840
1912
  description: "List of agent targets to generate rules for. Use ['all'] to generate rules for all supported AI agents."
1841
1913
  },
@@ -1850,6 +1922,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1850
1922
  {
1851
1923
  name: "guard_unmask_args",
1852
1924
  description: "Zero-Trust Agentic Guard: Detokenizes parameters, JSON objects, or arguments in local RAM before dispatching to local tools or APIs without sending cleartext secrets back to the LLM.",
1925
+ annotations: {
1926
+ title: "Unmask Tool Arguments in Local RAM",
1927
+ readOnlyHint: true,
1928
+ destructiveHint: false,
1929
+ idempotentHint: true,
1930
+ openWorldHint: false
1931
+ },
1853
1932
  inputSchema: {
1854
1933
  type: "object",
1855
1934
  properties: {
@@ -1867,6 +1946,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1867
1946
  {
1868
1947
  name: "guard_session_info",
1869
1948
  description: "Zero-Trust Agentic Guard: Returns diagnostic metrics on active in-memory sessions, token counts, and memory footprint with zero plaintext leakage.",
1949
+ annotations: {
1950
+ title: "Inspect RAM Session Diagnostics",
1951
+ readOnlyHint: true,
1952
+ destructiveHint: false,
1953
+ idempotentHint: true,
1954
+ openWorldHint: false
1955
+ },
1870
1956
  inputSchema: {
1871
1957
  type: "object",
1872
1958
  properties: {
@@ -1881,6 +1967,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1881
1967
  {
1882
1968
  name: "guard_session_reset",
1883
1969
  description: "Zero-Trust Agentic Guard: Explicitly purges the in-memory token map for a specific session_id or all sessions upon agent task completion.",
1970
+ annotations: {
1971
+ title: "Reset RAM Session Token Map",
1972
+ readOnlyHint: true,
1973
+ destructiveHint: false,
1974
+ idempotentHint: true,
1975
+ openWorldHint: false
1976
+ },
1884
1977
  inputSchema: {
1885
1978
  type: "object",
1886
1979
  properties: {
@@ -1899,6 +1992,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1899
1992
  {
1900
1993
  name: "guard_rag_chunk",
1901
1994
  description: "Zero-Trust Agentic Guard: Sanitizes an array of document chunks or knowledge base records before sending to embedding models or vector databases (Chroma, Pinecone, Qdrant). Keeps authentic data in local RAM with zero network leakage.",
1995
+ annotations: {
1996
+ title: "Sanitize RAG Document Chunks",
1997
+ readOnlyHint: true,
1998
+ destructiveHint: false,
1999
+ idempotentHint: true,
2000
+ openWorldHint: false
2001
+ },
1902
2002
  inputSchema: {
1903
2003
  type: "object",
1904
2004
  properties: {
@@ -1939,6 +2039,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1939
2039
  {
1940
2040
  name: "guard_rag_restore",
1941
2041
  description: "Zero-Trust Agentic Guard: Re-hydrates retrieved vector search results or chunks by restoring token placeholders with authentic data from the volatile RAM session before presenting to the user.",
2042
+ annotations: {
2043
+ title: "Restore RAG Vector Search Results",
2044
+ readOnlyHint: true,
2045
+ destructiveHint: false,
2046
+ idempotentHint: true,
2047
+ openWorldHint: false
2048
+ },
1942
2049
  inputSchema: {
1943
2050
  type: "object",
1944
2051
  properties: {
@@ -1972,6 +2079,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1972
2079
  {
1973
2080
  name: "export_audit_log",
1974
2081
  description: "Zero-Trust Enterprise Compliance: Exports an immutable, tamper-evident audit log in SIEM standard formats (CEF / ArcSight, RFC 5424 Syslog, JSONL, or JSON) with zero plaintext PII leakage. Can write directly to a local log file or stream.",
2082
+ annotations: {
2083
+ title: "Export SIEM Compliance Audit Log",
2084
+ readOnlyHint: false,
2085
+ destructiveHint: false,
2086
+ idempotentHint: true,
2087
+ openWorldHint: false
2088
+ },
1975
2089
  inputSchema: {
1976
2090
  type: "object",
1977
2091
  properties: {
@@ -2006,6 +2120,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2006
2120
  {
2007
2121
  name: "generate_procurement_memo",
2008
2122
  description: "Zero-Trust Enterprise Procurement: Generates an executive-ready Procurement & ROI Memorandum for Chief Information Security Officers (CISO), Data Protection Officers (DPO), and VP of Engineering. Compares TCO of traditional Cloud DLP ($1.5k–$4k/mo) vs PrivacyScrubber TEAMS ($99/mo flat) or Developer SDK ($299/mo flat), citing statutory frameworks (GDPR Art. 4/28, HIPAA §164.514, SOC 2 Type II, PCI DSS v4.0, NIST SP 800-53, EU AI Act Art. 50).",
2123
+ annotations: {
2124
+ title: "Generate CISO Procurement & ROI Memo",
2125
+ readOnlyHint: true,
2126
+ destructiveHint: false,
2127
+ idempotentHint: true,
2128
+ openWorldHint: false
2129
+ },
2009
2130
  inputSchema: {
2010
2131
  type: "object",
2011
2132
  properties: {
@@ -2178,6 +2299,75 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2178
2299
  throw new Error(`Resource not found: ${uri}`);
2179
2300
  });
2180
2301
 
2302
+ function getEffectiveWorkspaceRoots() {
2303
+ const roots = [];
2304
+ const addCandidate = (dir) => {
2305
+ if (!dir || typeof dir !== 'string') return;
2306
+ const trimmed = dir.trim();
2307
+ if (!trimmed) return;
2308
+ try {
2309
+ const resolved = fs.existsSync(trimmed) ? fs.realpathSync(trimmed) : path.resolve(trimmed);
2310
+ if (!roots.includes(resolved)) roots.push(resolved);
2311
+ } catch (_) {
2312
+ const fallback = path.resolve(trimmed);
2313
+ if (!roots.includes(fallback)) roots.push(fallback);
2314
+ }
2315
+ };
2316
+
2317
+ addCandidate(process.cwd());
2318
+ addCandidate(process.env.PRIVACYSCRUBBER_WORKSPACE);
2319
+ addCandidate(process.env.CURSOR_WORKSPACE_PATH);
2320
+ addCandidate(process.env.VSCODE_CWD);
2321
+ addCandidate(process.env.PROJECT_ROOT);
2322
+ addCandidate(process.env.INIT_CWD);
2323
+ if (process.env.WORKSPACE_FOLDER_PATHS) {
2324
+ for (const part of process.env.WORKSPACE_FOLDER_PATHS.split(path.delimiter)) {
2325
+ addCandidate(part);
2326
+ }
2327
+ }
2328
+ return roots;
2329
+ }
2330
+
2331
+ function isWithinAnyWorkspaceRoot(targetPath, roots) {
2332
+ for (const root of roots) {
2333
+ const rel = path.relative(root, targetPath);
2334
+ if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
2335
+ return { ok: true, matchedRoot: root };
2336
+ }
2337
+ }
2338
+ return { ok: false, matchedRoot: roots[0] || process.cwd() };
2339
+ }
2340
+
2341
+ function resolveWorkspaceSafePath(inputPath, paramName = "file_path") {
2342
+ const roots = getEffectiveWorkspaceRoots();
2343
+ const primaryRoot = roots[0];
2344
+ const resolvedPath = path.resolve(primaryRoot, inputPath);
2345
+ const boundaryCheck = isWithinAnyWorkspaceRoot(resolvedPath, roots);
2346
+ if (!boundaryCheck.ok) {
2347
+ return {
2348
+ ok: false,
2349
+ error: `Error: Security policy violation — target '${paramName}' resolves outside the active workspace directory (path traversal blocked).`
2350
+ };
2351
+ }
2352
+ let checkCursor = resolvedPath;
2353
+ while (!fs.existsSync(checkCursor)) {
2354
+ const parentCursor = path.dirname(checkCursor);
2355
+ if (parentCursor === checkCursor) break;
2356
+ checkCursor = parentCursor;
2357
+ }
2358
+ if (fs.existsSync(checkCursor)) {
2359
+ const realCheck = fs.realpathSync(checkCursor);
2360
+ const realBoundary = isWithinAnyWorkspaceRoot(realCheck, roots);
2361
+ if (!realBoundary.ok) {
2362
+ return {
2363
+ ok: false,
2364
+ error: `Error: Security policy violation — target path symlink escapes the active workspace directory.`
2365
+ };
2366
+ }
2367
+ }
2368
+ return { ok: true, resolvedPath, workspaceRoot: boundaryCheck.matchedRoot };
2369
+ }
2370
+
2181
2371
  // Handle tool execution calls
2182
2372
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2183
2373
  const { name, arguments: args } = request.params;
@@ -2210,18 +2400,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2210
2400
  const result = PrivacyScrubberCore.scrubText(text, customRules, {}, normalizedProfile, dryRunMap, license.isPro);
2211
2401
  const telemetry = buildCisoAuditTelemetry(result.tokenMap || {});
2212
2402
 
2403
+ const structuredAudit = {
2404
+ riskLevel: telemetry.riskLevel,
2405
+ totalDetected: telemetry.totalDetected,
2406
+ entities: telemetry.entities,
2407
+ triggeredFrameworks: telemetry.triggeredFrameworks,
2408
+ profile: targetProfile,
2409
+ executionEngine: "ZTDS Client-Side RAM"
2410
+ };
2411
+
2213
2412
  if (format === "json") {
2214
2413
  return {
2414
+ structuredContent: structuredAudit,
2215
2415
  content: [{
2216
2416
  type: "text",
2217
- text: JSON.stringify({
2218
- riskLevel: telemetry.riskLevel,
2219
- totalDetected: telemetry.totalDetected,
2220
- entities: telemetry.entities,
2221
- triggeredFrameworks: telemetry.triggeredFrameworks,
2222
- profile: targetProfile,
2223
- executionEngine: "ZTDS Client-Side RAM"
2224
- }, null, 2)
2417
+ text: JSON.stringify(structuredAudit, null, 2)
2225
2418
  }]
2226
2419
  };
2227
2420
  }
@@ -2246,6 +2439,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2246
2439
  }
2247
2440
 
2248
2441
  return {
2442
+ structuredContent: structuredAudit,
2249
2443
  content: [{ type: "text", text: report }]
2250
2444
  };
2251
2445
  }
@@ -2306,6 +2500,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2306
2500
  const receiptMd = formatAuditReceipt(telemetry, compact, wasTruncated, charLimit);
2307
2501
 
2308
2502
  return {
2503
+ structuredContent: {
2504
+ scrubbedText,
2505
+ tokensRedacted: telemetry.totalCount,
2506
+ riskLevel: telemetry.riskLevel,
2507
+ entities: telemetry.entities,
2508
+ frameworks: telemetry.frameworksList,
2509
+ profile: finalProfile,
2510
+ sessionId: session_id,
2511
+ wasTruncated: Boolean(wasTruncated)
2512
+ },
2309
2513
  content: [
2310
2514
  { type: "text", text: scrubbedText + receiptMd }
2311
2515
  ]
@@ -2375,6 +2579,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2375
2579
  restoredText = restoredText.replace(/\b(mysql|postgresql|postgres|redis|mongodb|https?|ftp|ssh|git|aws):\/\/\1:\/\//gi, '$1://');
2376
2580
 
2377
2581
  return {
2582
+ structuredContent: {
2583
+ restoredText,
2584
+ restoredCount: restored.restoredCount || 0,
2585
+ sessionId: session_id
2586
+ },
2378
2587
  content: [
2379
2588
  {
2380
2589
  type: "text",
@@ -2457,7 +2666,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2457
2666
  }
2458
2667
 
2459
2668
  if (name === "sanitize_file" || name === "scrub_file") {
2460
- const rawPath = args.file_path || args.filePath;
2669
+ const rawPath = args?.file_path || args?.filePath;
2461
2670
  if (!rawPath) {
2462
2671
  return {
2463
2672
  isError: true,
@@ -2474,8 +2683,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2474
2683
  const targetSession = getSessionMap(session_id);
2475
2684
  const targetIgnoreList = getSessionIgnoreList(session_id);
2476
2685
  const filePath = rawPath;
2477
-
2478
- const resolvedPath = path.resolve(filePath);
2686
+ const safeTarget = resolveWorkspaceSafePath(filePath, "file_path");
2687
+ if (!safeTarget.ok) {
2688
+ return {
2689
+ isError: true,
2690
+ content: [{ type: "text", text: safeTarget.error }]
2691
+ };
2692
+ }
2693
+ const resolvedPath = safeTarget.resolvedPath;
2479
2694
  if (!fs.existsSync(resolvedPath)) {
2480
2695
  return {
2481
2696
  isError: true,
@@ -2549,9 +2764,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2549
2764
  const dataStart = localOffset + 30 + lNameLen + lExtraLen;
2550
2765
  const compData = docxBuf.subarray(dataStart, dataStart + compSize);
2551
2766
  if (method === 0) {
2552
- zipEntries[entryName] = compData.toString('utf8');
2767
+ if (compData.length <= 20 * 1024 * 1024) {
2768
+ zipEntries[entryName] = compData.toString('utf8');
2769
+ }
2553
2770
  } else if (method === 8) {
2554
- zipEntries[entryName] = zlib.inflateRawSync(compData).toString('utf8');
2771
+ zipEntries[entryName] = zlib.inflateRawSync(compData, { maxOutputLength: 20 * 1024 * 1024 }).toString('utf8');
2555
2772
  }
2556
2773
  }
2557
2774
  pos += 46 + nameLen + extraLen + commentLen;
@@ -2875,6 +3092,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2875
3092
  const receiptMd = formatAuditReceipt(telemetry, compact, wasTruncated, charLimit);
2876
3093
 
2877
3094
  return {
3095
+ structuredContent: {
3096
+ scrubbedText,
3097
+ tokensRedacted: telemetry.totalCount,
3098
+ riskLevel: telemetry.riskLevel,
3099
+ entities: telemetry.entities,
3100
+ frameworks: telemetry.frameworksList,
3101
+ profile: finalProfile,
3102
+ sessionId: session_id,
3103
+ wasTruncated: Boolean(wasTruncated)
3104
+ },
2878
3105
  content: [
2879
3106
  { type: "text", text: scrubbedText + receiptMd }
2880
3107
  ]
@@ -2882,23 +3109,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2882
3109
  }
2883
3110
 
2884
3111
  if (name === "redact_file") {
2885
- const { file_path, dry_run, no_backup } = args;
2886
- if (!file_path || !fs.existsSync(file_path)) {
3112
+ const { file_path, dry_run, no_backup } = args || {};
3113
+ if (!file_path) {
3114
+ return { isError: true, content: [{ type: "text", text: "Error: Missing required parameter 'file_path'." }] };
3115
+ }
3116
+ const safeTarget = resolveWorkspaceSafePath(file_path, "file_path");
3117
+ if (!safeTarget.ok) {
3118
+ return { isError: true, content: [{ type: "text", text: safeTarget.error }] };
3119
+ }
3120
+ const resolvedRedactPath = safeTarget.resolvedPath;
3121
+ if (!fs.existsSync(resolvedRedactPath)) {
2887
3122
  return { isError: true, content: [{ type: "text", text: `Error: File not found at path: ${file_path}` }] };
2888
3123
  }
2889
3124
 
2890
3125
  try {
2891
- const stat = await fs.promises.stat(file_path);
3126
+ const lstat = await fs.promises.lstat(resolvedRedactPath);
3127
+ if (lstat.isSymbolicLink() || !lstat.isFile()) {
3128
+ return { isError: true, content: [{ type: "text", text: `Error: Target must be a regular non-symlink file.` }] };
3129
+ }
3130
+ const stat = lstat;
2892
3131
  if (stat.size > 50 * 1024 * 1024) {
2893
3132
  return { isError: true, content: [{ type: "text", text: `Error: File is too large (>50MB).` }] };
2894
3133
  }
2895
3134
 
2896
- const buffer = fs.readFileSync(file_path);
3135
+ const buffer = fs.readFileSync(resolvedRedactPath);
2897
3136
  if (buffer.indexOf(0) !== -1) {
2898
3137
  return { isError: true, content: [{ type: "text", text: `Error: Cannot redact binary file.` }] };
2899
3138
  }
2900
3139
  let content = buffer.toString('utf8');
2901
- const ext = path.extname(file_path).toLowerCase();
3140
+ const ext = path.extname(resolvedRedactPath).toLowerCase();
2902
3141
  if (ext === ".eml") {
2903
3142
  const decodeQpUtf8 = (str) =>
2904
3143
  str.replace(/=\r?\n/g, "").replace(/(?:=[0-9A-Fa-f]{2})+/g, (seq) => {
@@ -2992,15 +3231,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2992
3231
  }
2993
3232
 
2994
3233
  if (!no_backup) {
2995
- fs.writeFileSync(`${file_path}.bak`, content, 'utf8');
3234
+ fs.writeFileSync(`${resolvedRedactPath}.bak`, content, 'utf8');
2996
3235
  }
2997
3236
 
2998
- fs.writeFileSync(file_path, result.scrubbedText, 'utf8');
3237
+ fs.writeFileSync(resolvedRedactPath, result.scrubbedText, 'utf8');
2999
3238
 
3000
3239
  return {
3001
3240
  content: [{
3002
3241
  type: "text",
3003
- text: `✅ File successfully redacted.\nReplaced ${Object.keys(localSessionMap).length} tokens (${summary}).\n${!no_backup ? `Backup saved to ${file_path}.bak` : "No backup created (no_backup=true)."}`
3242
+ text: `✅ File successfully redacted.\nReplaced ${Object.keys(localSessionMap).length} tokens (${summary}).\n${!no_backup ? `Backup saved to ${resolvedRedactPath}.bak` : "No backup created (no_backup=true)."}`
3004
3243
  }]
3005
3244
  };
3006
3245
 
@@ -3168,8 +3407,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3168
3407
  }
3169
3408
 
3170
3409
  if (name === "audit_directory_for_pii") {
3171
- const { directory_path, extensions, ignore_node_modules } = args;
3172
- if (!directory_path || !fs.existsSync(directory_path)) {
3410
+ const { directory_path, extensions, ignore_node_modules } = args || {};
3411
+ if (!directory_path) {
3412
+ return { isError: true, content: [{ type: "text", text: "Error: Missing required parameter 'directory_path'." }] };
3413
+ }
3414
+ const safeDir = resolveWorkspaceSafePath(directory_path, "directory_path");
3415
+ if (!safeDir.ok) {
3416
+ return { isError: true, content: [{ type: "text", text: safeDir.error }] };
3417
+ }
3418
+ if (!fs.existsSync(safeDir.resolvedPath)) {
3173
3419
  return { isError: true, content: [{ type: "text", text: `Error: Directory not found at path: ${directory_path}` }] };
3174
3420
  }
3175
3421
 
@@ -3179,20 +3425,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3179
3425
  async function walkDir(dir) {
3180
3426
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
3181
3427
  for (const entry of entries) {
3428
+ if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
3182
3429
  const fullPath = path.join(dir, entry.name);
3430
+ const safeChild = resolveWorkspaceSafePath(fullPath, "directory_path");
3431
+ if (!safeChild.ok) continue;
3183
3432
  if (entry.isDirectory()) {
3184
- if (!ignoreDirs.includes(entry.name)) await walkDir(fullPath);
3433
+ if (!ignoreDirs.includes(entry.name)) await walkDir(safeChild.resolvedPath);
3185
3434
  } else if (entry.isFile()) {
3186
3435
  if (extensions && extensions.length > 0) {
3187
3436
  const ext = path.extname(entry.name);
3188
3437
  if (!extensions.includes(ext)) continue;
3189
3438
  }
3190
- filesToScan.push(fullPath);
3439
+ filesToScan.push(safeChild.resolvedPath);
3191
3440
  }
3192
3441
  }
3193
3442
  }
3194
3443
 
3195
- await walkDir(directory_path);
3444
+ await walkDir(safeDir.resolvedPath);
3196
3445
 
3197
3446
  let reportLines = [`## Security Audit Report: ${directory_path}`];
3198
3447
  let filesWithIssues = 0;
@@ -3302,7 +3551,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3302
3551
  // Transparent in-memory unmasking of any token placeholders before local execution
3303
3552
  const rawExecutableCommand = reverseTokens(command, targetSession);
3304
3553
 
3305
- const execCwd = cwd ? path.resolve(cwd) : process.cwd();
3554
+ let execCwd = process.cwd();
3555
+ if (cwd) {
3556
+ const safeCwd = resolveWorkspaceSafePath(cwd, "cwd");
3557
+ if (!safeCwd.ok) {
3558
+ return { isError: true, content: [{ type: "text", text: safeCwd.error }] };
3559
+ }
3560
+ execCwd = safeCwd.resolvedPath;
3561
+ }
3306
3562
  let stdout = '';
3307
3563
  let stderr = '';
3308
3564
  let exitCode = 0;
@@ -3358,7 +3614,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3358
3614
  content: [{ type: "text", text: "Error: Missing required parameter 'file_path'." }]
3359
3615
  };
3360
3616
  }
3361
- const resolvedPath = path.resolve(process.cwd(), file_path);
3617
+ const safeTarget = resolveWorkspaceSafePath(file_path, "file_path");
3618
+ if (!safeTarget.ok) {
3619
+ return {
3620
+ isError: true,
3621
+ content: [{ type: "text", text: safeTarget.error }]
3622
+ };
3623
+ }
3624
+ const resolvedPath = safeTarget.resolvedPath;
3362
3625
  if (!fs.existsSync(resolvedPath)) {
3363
3626
  return {
3364
3627
  isError: true,
@@ -3379,23 +3642,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3379
3642
  };
3380
3643
  }
3381
3644
  const rawBuffer = fs.readFileSync(resolvedPath);
3382
- const checkLen = Math.min(rawBuffer.length, 8000);
3383
- for (let i = 0; i < checkLen; i++) {
3384
- if (rawBuffer[i] === 0) {
3385
- return {
3386
- isError: true,
3387
- content: [{ type: "text", text: `Error: Binary file format detected. Use sanitize_file for DOCX/PDF/XLSX documents.` }]
3388
- };
3389
- }
3390
- }
3645
+ const ext = path.extname(resolvedPath).toLowerCase();
3391
3646
  const license = checkLicenseStatus();
3392
3647
  const limitStatus = checkFreeTierLimit(license.isPro);
3393
3648
  if (limitStatus.blocked) {
3394
3649
  return buildDailyLimitBlockedResponse();
3395
3650
  }
3396
3651
 
3397
- let rawContent = rawBuffer.toString('utf8');
3398
- const ext = path.extname(resolvedPath).toLowerCase();
3652
+ let rawContent = '';
3653
+ if (ext === '.docx') {
3654
+ try {
3655
+ const mammoth = require('mammoth');
3656
+ const result = await mammoth.extractRawText({ buffer: rawBuffer });
3657
+ rawContent = result.value || '';
3658
+ } catch (docxErr) {
3659
+ return {
3660
+ isError: true,
3661
+ content: [{ type: "text", text: `Error: Failed to parse DOCX file: ${docxErr.message}` }]
3662
+ };
3663
+ }
3664
+ } else if (ext === '.pdf' || ext === '.xlsx' || ext === '.xls') {
3665
+ if (!license.isPro) {
3666
+ return {
3667
+ isError: true,
3668
+ content: [{
3669
+ type: "text",
3670
+ text: `🔒 [PrivacyScrubber PRO / TEAMS] PDF and Excel file sanitization is a PRO feature (${ext === '.pdf' ? 'PDF' : 'Excel/XLSX'} detected).\n\n👉 7-Day Free B2B Trial (No card): https://privacyscrubber.com/pricing/?tier=teams&trial=true&utm_source=mcp_cli&utm_medium=file_type&utm_campaign=b2b_trial`
3671
+ }]
3672
+ };
3673
+ }
3674
+ if (ext === '.pdf') {
3675
+ try {
3676
+ const pdf = require('pdf-parse');
3677
+ const data = await pdf(rawBuffer);
3678
+ rawContent = data.text || '';
3679
+ } catch (pdfErr) {
3680
+ return {
3681
+ isError: true,
3682
+ content: [{ type: "text", text: `Error: Failed to parse PDF file: ${pdfErr.message}` }]
3683
+ };
3684
+ }
3685
+ } else {
3686
+ try {
3687
+ const XLSX = require('xlsx');
3688
+ const workbook = XLSX.read(rawBuffer, { type: 'buffer' });
3689
+ workbook.SheetNames.forEach(sheetName => {
3690
+ rawContent += `--- Sheet: ${sheetName} ---\n` + XLSX.utils.sheet_to_csv(workbook.Sheets[sheetName]) + '\n';
3691
+ });
3692
+ } catch (xlsxErr) {
3693
+ return {
3694
+ isError: true,
3695
+ content: [{ type: "text", text: `Error: Failed to parse Excel file: ${xlsxErr.message}` }]
3696
+ };
3697
+ }
3698
+ }
3699
+ } else {
3700
+ const checkLen = Math.min(rawBuffer.length, 8000);
3701
+ for (let i = 0; i < checkLen; i++) {
3702
+ if (rawBuffer[i] === 0) {
3703
+ return {
3704
+ isError: true,
3705
+ content: [{ type: "text", text: `Error: Binary file format detected. Only plain text, source code, and document formats (.docx, .pdf, .xlsx, .eml, .rtf) are supported.` }]
3706
+ };
3707
+ }
3708
+ }
3709
+ rawContent = rawBuffer.toString('utf8');
3710
+ }
3399
3711
  if (ext === '.eml') {
3400
3712
  const decodeQpUtf8 = (str) =>
3401
3713
  str.replace(/=\r?\n/g, '').replace(/(?:=[0-9A-Fa-f]{2})+/g, (seq) => {
@@ -3464,7 +3776,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3464
3776
  const { staged = false, cwd, session_id = "default" } = args || {};
3465
3777
  const targetSession = getSessionMap(session_id);
3466
3778
  const targetIgnoreList = getSessionIgnoreList(session_id);
3467
- const repoCwd = cwd ? path.resolve(cwd) : process.cwd();
3779
+ let repoCwd = process.cwd();
3780
+ if (cwd) {
3781
+ const safeCwd = resolveWorkspaceSafePath(cwd, "cwd");
3782
+ if (!safeCwd.ok) {
3783
+ return { isError: true, content: [{ type: "text", text: safeCwd.error }] };
3784
+ }
3785
+ repoCwd = safeCwd.resolvedPath;
3786
+ }
3468
3787
  const license = checkLicenseStatus();
3469
3788
  const limitStatus = checkFreeTierLimit(license.isPro);
3470
3789
  if (limitStatus.blocked) {
@@ -3528,7 +3847,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3528
3847
  };
3529
3848
  }
3530
3849
 
3531
- const resolvedPath = path.resolve(process.cwd(), file_path);
3850
+ const safeTarget = resolveWorkspaceSafePath(file_path, "file_path");
3851
+ if (!safeTarget.ok) {
3852
+ return {
3853
+ isError: true,
3854
+ content: [{ type: "text", text: safeTarget.error }]
3855
+ };
3856
+ }
3857
+ const resolvedPath = safeTarget.resolvedPath;
3858
+
3859
+ const license = checkLicenseStatus();
3860
+ const limitStatus = checkFreeTierLimit(license.isPro);
3861
+ if (limitStatus.blocked) {
3862
+ return buildDailyLimitBlockedResponse();
3863
+ }
3864
+ if (fs.existsSync(resolvedPath)) {
3865
+ const existingLstat = fs.lstatSync(resolvedPath);
3866
+ if (existingLstat.isSymbolicLink() || !existingLstat.isFile()) {
3867
+ return {
3868
+ isError: true,
3869
+ content: [{ type: "text", text: "Error: Security policy violation — target must be a regular non-symlink file." }]
3870
+ };
3871
+ }
3872
+ }
3873
+
3532
3874
  const targetSession = getSessionMap(session_id);
3533
3875
  const restored = PrivacyScrubberCore.unscrubText(content, targetSession);
3534
3876
  const authenticContent = restored.restoredText;
@@ -3561,8 +3903,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3561
3903
 
3562
3904
  if (name === "create_agent_rules") {
3563
3905
  const { agent_types = ["all"], workspace_dir } = args || {};
3564
- const targetDir = workspace_dir ? path.resolve(workspace_dir) : process.cwd();
3565
- const targets = agent_types.includes("all") ? ["cursor", "windsurf", "claude_code", "copilot", "cline"] : agent_types;
3906
+ let targetDir = process.cwd();
3907
+ if (workspace_dir) {
3908
+ const safeWorkspace = resolveWorkspaceSafePath(workspace_dir, "workspace_dir");
3909
+ if (!safeWorkspace.ok) {
3910
+ return { isError: true, content: [{ type: "text", text: safeWorkspace.error }] };
3911
+ }
3912
+ targetDir = safeWorkspace.resolvedPath;
3913
+ }
3914
+ const targets = agent_types.includes("all") ? ["cursor", "windsurf", "claude_code", "copilot", "cline", "agents_md"] : agent_types;
3566
3915
 
3567
3916
  const ruleText = `# PrivacyScrubber Zero-Trust Agentic Guard (CISO Security Rules)
3568
3917
  # 100% Local In-Memory Data Sanitization. Zero Network Egress.
@@ -3586,6 +3935,14 @@ Before reading sensitive files, running terminal commands that may print credent
3586
3935
  const p = path.join(targetDir, '.cursorrules');
3587
3936
  fs.writeFileSync(p, ruleText, 'utf8');
3588
3937
  generatedFiles.push('.cursorrules');
3938
+
3939
+ const cursorRulesDir = path.join(targetDir, '.cursor', 'rules');
3940
+ try {
3941
+ if (!fs.existsSync(cursorRulesDir)) fs.mkdirSync(cursorRulesDir, { recursive: true });
3942
+ const mdcContent = `---\ndescription: PrivacyScrubber Zero-Trust Data Sanitization (ZTDS) Agentic Guard\nglobs: *\nalwaysApply: true\n---\n\n${ruleText}`;
3943
+ fs.writeFileSync(path.join(cursorRulesDir, 'privacyscrubber.mdc'), mdcContent, 'utf8');
3944
+ generatedFiles.push('.cursor/rules/privacyscrubber.mdc');
3945
+ } catch (_) {}
3589
3946
  }
3590
3947
  if (targets.includes("windsurf")) {
3591
3948
  const p = path.join(targetDir, '.windsurfrules');
@@ -3605,6 +3962,19 @@ Before reading sensitive files, running terminal commands that may print credent
3605
3962
  generatedFiles.push('CLAUDE.md');
3606
3963
  }
3607
3964
  }
3965
+ if (targets.includes("agents_md") || targets.includes("codex")) {
3966
+ const p = path.join(targetDir, 'AGENTS.md');
3967
+ if (fs.existsSync(p)) {
3968
+ const existing = fs.readFileSync(p, 'utf8');
3969
+ if (!existing.includes("Zero-Trust Agentic Guard")) {
3970
+ fs.writeFileSync(p, existing + "\n\n" + ruleText, 'utf8');
3971
+ generatedFiles.push('AGENTS.md (appended)');
3972
+ }
3973
+ } else {
3974
+ fs.writeFileSync(p, ruleText, 'utf8');
3975
+ generatedFiles.push('AGENTS.md');
3976
+ }
3977
+ }
3608
3978
  if (targets.includes("copilot")) {
3609
3979
  const copilotDir = path.join(targetDir, '.github');
3610
3980
  if (!fs.existsSync(copilotDir)) fs.mkdirSync(copilotDir, { recursive: true });
@@ -3618,7 +3988,57 @@ Before reading sensitive files, running terminal commands that may print credent
3618
3988
  generatedFiles.push('.clinerules');
3619
3989
  }
3620
3990
 
3991
+ if (args?.include_hooks !== false && (targets.includes("cursor") || targets.includes("claude_code") || agent_types.includes("all"))) {
3992
+ const hookCmd = "npx -y @privacyscrubber/mcp-server --hook";
3993
+ if (targets.includes("cursor") || agent_types.includes("all")) {
3994
+ try {
3995
+ const cursorHooksPath = path.join(targetDir, '.cursor', 'hooks.json');
3996
+ fs.mkdirSync(path.dirname(cursorHooksPath), { recursive: true });
3997
+ let cursorHooks = { version: 1, hooks: {} };
3998
+ if (fs.existsSync(cursorHooksPath)) {
3999
+ try { cursorHooks = JSON.parse(fs.readFileSync(cursorHooksPath, 'utf8')); } catch (_) {}
4000
+ }
4001
+ if (!cursorHooks.hooks || typeof cursorHooks.hooks !== 'object') cursorHooks.hooks = {};
4002
+ for (const ev of ['beforeReadFile', 'beforeShellExecution']) {
4003
+ if (!Array.isArray(cursorHooks.hooks[ev])) cursorHooks.hooks[ev] = [];
4004
+ if (!cursorHooks.hooks[ev].some(h => h && h.command && h.command.includes('privacyscrubber'))) {
4005
+ cursorHooks.hooks[ev].push({ command: hookCmd });
4006
+ }
4007
+ }
4008
+ fs.writeFileSync(cursorHooksPath, JSON.stringify(cursorHooks, null, 2) + '\n', 'utf8');
4009
+ generatedFiles.push('.cursor/hooks.json');
4010
+ } catch (_) {}
4011
+ }
4012
+ if (targets.includes("claude_code") || agent_types.includes("all")) {
4013
+ try {
4014
+ const claudeSettingsPath = path.join(targetDir, '.claude', 'settings.json');
4015
+ fs.mkdirSync(path.dirname(claudeSettingsPath), { recursive: true });
4016
+ let claudeSettings = {};
4017
+ if (fs.existsSync(claudeSettingsPath)) {
4018
+ try { claudeSettings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8')); } catch (_) {}
4019
+ }
4020
+ if (!claudeSettings.hooks || typeof claudeSettings.hooks !== 'object') claudeSettings.hooks = {};
4021
+ if (!Array.isArray(claudeSettings.hooks.PreToolUse)) claudeSettings.hooks.PreToolUse = [];
4022
+ const hasClaudeHook = claudeSettings.hooks.PreToolUse.some(entry =>
4023
+ Array.isArray(entry?.hooks) && entry.hooks.some(h => h && h.command && h.command.includes('privacyscrubber'))
4024
+ );
4025
+ if (!hasClaudeHook) {
4026
+ claudeSettings.hooks.PreToolUse.push({
4027
+ matcher: "Read|Bash",
4028
+ hooks: [{ type: "command", command: hookCmd }]
4029
+ });
4030
+ }
4031
+ fs.writeFileSync(claudeSettingsPath, JSON.stringify(claudeSettings, null, 2) + '\n', 'utf8');
4032
+ generatedFiles.push('.claude/settings.json');
4033
+ } catch (_) {}
4034
+ }
4035
+ }
4036
+
3621
4037
  return {
4038
+ structuredContent: {
4039
+ workspace: targetDir,
4040
+ generatedFiles
4041
+ },
3622
4042
  content: [{
3623
4043
  type: "text",
3624
4044
  text: `[Zero-Trust Agentic Guard: Agent Rules Generated]\n` +
@@ -3641,22 +4061,38 @@ Before reading sensitive files, running terminal commands that may print credent
3641
4061
  }
3642
4062
 
3643
4063
  const targetSession = getSessionMap(session_id);
4064
+ const seenUnmask = new WeakMap();
3644
4065
 
3645
4066
  function unmaskRecursive(val) {
3646
4067
  if (typeof val === 'string') {
3647
4068
  return reverseTokens(val, targetSession);
3648
4069
  }
3649
- if (Array.isArray(val)) {
3650
- return val.map(unmaskRecursive);
4070
+ if (val === null || typeof val !== 'object') return val;
4071
+ if (
4072
+ val instanceof Date ||
4073
+ val instanceof RegExp ||
4074
+ val instanceof Error ||
4075
+ ArrayBuffer.isView(val) ||
4076
+ (typeof ArrayBuffer !== 'undefined' && val instanceof ArrayBuffer)
4077
+ ) {
4078
+ return val;
3651
4079
  }
3652
- if (val !== null && typeof val === 'object') {
3653
- const res = {};
3654
- for (const [k, v] of Object.entries(val)) {
3655
- res[k] = unmaskRecursive(v);
4080
+ if (seenUnmask.has(val)) return seenUnmask.get(val);
4081
+ if (Array.isArray(val)) {
4082
+ const outArr = [];
4083
+ seenUnmask.set(val, outArr);
4084
+ for (let i = 0; i < val.length; i++) {
4085
+ outArr.push(unmaskRecursive(val[i]));
3656
4086
  }
3657
- return res;
4087
+ return outArr;
4088
+ }
4089
+ const res = {};
4090
+ seenUnmask.set(val, res);
4091
+ for (const [k, v] of Object.entries(val)) {
4092
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
4093
+ res[k] = unmaskRecursive(v);
3658
4094
  }
3659
- return val;
4095
+ return res;
3660
4096
  }
3661
4097
 
3662
4098
  const unmasked = unmaskRecursive(targetArgs);
@@ -3669,17 +4105,20 @@ Before reading sensitive files, running terminal commands that may print credent
3669
4105
  }
3670
4106
  }
3671
4107
 
4108
+ const unmaskPayload = {
4109
+ status: "success",
4110
+ success: true,
4111
+ session_id,
4112
+ tokens_restored: tokensRestored,
4113
+ unmasked_count: tokensRestored,
4114
+ unmasked_args: unmasked
4115
+ };
4116
+
3672
4117
  return {
4118
+ structuredContent: unmaskPayload,
3673
4119
  content: [{
3674
4120
  type: "text",
3675
- text: JSON.stringify({
3676
- status: "success",
3677
- success: true,
3678
- session_id,
3679
- tokens_restored: tokensRestored,
3680
- unmasked_count: tokensRestored,
3681
- unmasked_args: unmasked
3682
- }, null, 2)
4121
+ text: JSON.stringify(unmaskPayload, null, 2)
3683
4122
  }]
3684
4123
  };
3685
4124
  }
@@ -3693,6 +4132,7 @@ Before reading sensitive files, running terminal commands that may print credent
3693
4132
  const session = agentSessions.get(sid);
3694
4133
  if (!session) {
3695
4134
  return {
4135
+ structuredContent: { sessionId: sid, exists: false, totalTokens: 0 },
3696
4136
  content: [{
3697
4137
  type: "text",
3698
4138
  text: `[Zero-Trust Agentic Guard: Session Info]\nSession '${sid}' does not exist or has expired.`
@@ -3703,6 +4143,16 @@ Before reading sensitive files, running terminal commands that may print credent
3703
4143
  const telemetry = buildCisoAuditTelemetry(session.map);
3704
4144
  const streamBufferLen = session.streamDetokenizer ? session.streamDetokenizer.buffer.length : 0;
3705
4145
  return {
4146
+ structuredContent: {
4147
+ sessionId: sid,
4148
+ exists: true,
4149
+ createdAt: new Date(session.createdAt).toISOString(),
4150
+ lastActive: new Date(session.lastActive).toISOString(),
4151
+ totalTokens: tokenEntries.length,
4152
+ streamBufferBytes: streamBufferLen,
4153
+ entities: telemetry.entities,
4154
+ riskLevel: telemetry.riskLevel
4155
+ },
3706
4156
  content: [{
3707
4157
  type: "text",
3708
4158
  text: `[Zero-Trust Agentic Guard: Session Info - ${sid}]\n` +
@@ -3727,6 +4177,13 @@ Before reading sensitive files, running terminal commands that may print credent
3727
4177
  }
3728
4178
 
3729
4179
  return {
4180
+ structuredContent: {
4181
+ activeSessions: agentSessions.size,
4182
+ totalTokens,
4183
+ heapUsedMb: Number((memUsage.heapUsed / (1024 * 1024)).toFixed(2)),
4184
+ uptimeSeconds: Math.floor(process.uptime()),
4185
+ sessions: sessionList
4186
+ },
3730
4187
  content: [{
3731
4188
  type: "text",
3732
4189
  text: `[Zero-Trust Agentic Guard: Global Session Diagnostics]\n` +
@@ -3825,19 +4282,21 @@ Before reading sensitive files, running terminal commands that may print credent
3825
4282
 
3826
4283
  const telemetry = buildCisoAuditTelemetry(targetSession);
3827
4284
  const auditReceipt = formatAuditReceipt(telemetry);
4285
+ const ragChunkPayload = {
4286
+ status: "success",
4287
+ session_id,
4288
+ total_chunks: chunks.length,
4289
+ tokens_redacted_in_batch: totalTokensRedacted,
4290
+ total_session_tokens: Object.keys(targetSession).length,
4291
+ risk_level: telemetry.riskLevel,
4292
+ sanitized_chunks: sanitizedChunks
4293
+ };
3828
4294
 
3829
4295
  return {
4296
+ structuredContent: ragChunkPayload,
3830
4297
  content: [{
3831
4298
  type: "text",
3832
- text: JSON.stringify({
3833
- status: "success",
3834
- session_id,
3835
- total_chunks: chunks.length,
3836
- tokens_redacted_in_batch: totalTokensRedacted,
3837
- total_session_tokens: Object.keys(targetSession).length,
3838
- risk_level: telemetry.riskLevel,
3839
- sanitized_chunks: sanitizedChunks
3840
- }, null, 2) + `\n\n${auditReceipt}`
4299
+ text: JSON.stringify(ragChunkPayload, null, 2) + `\n\n${auditReceipt}`
3841
4300
  }]
3842
4301
  };
3843
4302
  }
@@ -3885,16 +4344,19 @@ Before reading sensitive files, running terminal commands that may print credent
3885
4344
  }
3886
4345
  }
3887
4346
 
4347
+ const ragRestorePayload = {
4348
+ status: "success",
4349
+ session_id,
4350
+ total_results: results.length,
4351
+ tokens_restored: totalTokensRestored,
4352
+ restored_results: restoredResults
4353
+ };
4354
+
3888
4355
  return {
4356
+ structuredContent: ragRestorePayload,
3889
4357
  content: [{
3890
4358
  type: "text",
3891
- text: JSON.stringify({
3892
- status: "success",
3893
- session_id,
3894
- total_results: results.length,
3895
- tokens_restored: totalTokensRestored,
3896
- restored_results: restoredResults
3897
- }, null, 2)
4359
+ text: JSON.stringify(ragRestorePayload, null, 2)
3898
4360
  }]
3899
4361
  };
3900
4362
  }
@@ -3923,6 +4385,9 @@ Before reading sensitive files, running terminal commands that may print credent
3923
4385
  const sessionHash = receipt.receiptSha256;
3924
4386
 
3925
4387
  let outputText = "";
4388
+ const safeCompanyName = String(company_name || "Organization").replace(/[\r\n\x00-\x1f]/g, " ").trim().slice(0, 120);
4389
+ const safeDepartment = String(department || "Security & Compliance").replace(/[\r\n\x00-\x1f]/g, " ").trim().slice(0, 120);
4390
+
3926
4391
  const eventData = {
3927
4392
  vendor: "PrivacyScrubber",
3928
4393
  product: "ZTDS",
@@ -3933,8 +4398,8 @@ Before reading sensitive files, running terminal commands that may print credent
3933
4398
  entities: telemetry.entities,
3934
4399
  frameworks: telemetry.frameworksList,
3935
4400
  sessionHash,
3936
- organization: company_name,
3937
- department
4401
+ organization: safeCompanyName,
4402
+ department: safeDepartment
3938
4403
  };
3939
4404
 
3940
4405
  if (format === "cef") {
@@ -3942,19 +4407,35 @@ Before reading sensitive files, running terminal commands that may print credent
3942
4407
  } else if (format === "syslog") {
3943
4408
  outputText = formatSyslogEvent(eventData);
3944
4409
  } else if (format === "json" || format === "markdown") {
3945
- outputText = formatPsr2Report({ format, receipt, telemetry, company_name, department, utmSource: 'mcp_audit_export', utmMedium: format });
4410
+ outputText = formatPsr2Report({ format, receipt, telemetry, company_name: safeCompanyName, department: safeDepartment, utmSource: 'mcp_audit_export', utmMedium: format });
3946
4411
  } else {
3947
4412
  // Default: jsonl
3948
4413
  outputText = formatJsonlEvent(eventData);
3949
4414
  }
3950
4415
 
3951
4416
  if (typeof destination_file === 'string' && destination_file.trim()) {
4417
+ const safeDest = resolveWorkspaceSafePath(destination_file.trim(), "destination_file");
4418
+ if (!safeDest.ok) {
4419
+ return {
4420
+ isError: true,
4421
+ content: [{ type: "text", text: safeDest.error }]
4422
+ };
4423
+ }
3952
4424
  try {
3953
- const dir = path.dirname(destination_file);
4425
+ if (fs.existsSync(safeDest.resolvedPath)) {
4426
+ const st = fs.lstatSync(safeDest.resolvedPath);
4427
+ if (st.isSymbolicLink() || !st.isFile()) {
4428
+ return {
4429
+ isError: true,
4430
+ content: [{ type: "text", text: "Error: Security policy violation — destination_file must be a regular non-symlink file." }]
4431
+ };
4432
+ }
4433
+ }
4434
+ const dir = path.dirname(safeDest.resolvedPath);
3954
4435
  if (!fs.existsSync(dir)) {
3955
4436
  fs.mkdirSync(dir, { recursive: true });
3956
4437
  }
3957
- fs.appendFileSync(destination_file, outputText + '\n', 'utf8');
4438
+ fs.appendFileSync(safeDest.resolvedPath, outputText + '\n', 'utf8');
3958
4439
  } catch (e) {
3959
4440
  return {
3960
4441
  isError: true,
@@ -3964,6 +4445,15 @@ Before reading sensitive files, running terminal commands that may print credent
3964
4445
  }
3965
4446
 
3966
4447
  return {
4448
+ structuredContent: {
4449
+ format,
4450
+ riskLevel: telemetry.riskLevel,
4451
+ tokensMasked: telemetry.totalCount,
4452
+ entities: telemetry.entities,
4453
+ frameworks: telemetry.frameworksList,
4454
+ receiptSha256: sessionHash,
4455
+ timestamp
4456
+ },
3967
4457
  content: [{
3968
4458
  type: "text",
3969
4459
  text: outputText
@@ -4051,6 +4541,18 @@ function getSessionMetricsSummary() {
4051
4541
  return parts.join(', ');
4052
4542
  }
4053
4543
 
4544
+ function isCatastrophicRegex(pat) {
4545
+ if (!pat || typeof pat !== 'string' || pat.length > 500) return true;
4546
+ const nestedQuantifierRegex = /\([^)]*(\+|\*|\{\d+,?\d*\})\)[+*]|\([^)]*(\+|\*|\{\d+,?\d*\})\)\{\d+,?\d*\}|\([^)]*\|[^)]*\)(?:[+*]|\{\d+,?\d*\})/;
4547
+ if (nestedQuantifierRegex.test(pat)) return true;
4548
+ try {
4549
+ new RegExp(pat, 'gi');
4550
+ return false;
4551
+ } catch (_) {
4552
+ return true;
4553
+ }
4554
+ }
4555
+
4054
4556
  function loadCustomRules() {
4055
4557
  const license = checkLicenseStatus();
4056
4558
  const configPath = resolveConfigPath();
@@ -4066,7 +4568,7 @@ function loadCustomRules() {
4066
4568
  label: (r.label || r.category || "CUSTOM").toUpperCase(),
4067
4569
  regex: r.pattern || r.regex || "",
4068
4570
  category: (r.label || r.category || "CUSTOM").toUpperCase()
4069
- })).filter(r => r.pattern);
4571
+ })).filter(r => r.pattern && !isCatastrophicRegex(r.pattern));
4070
4572
  } else {
4071
4573
  mcpLog(`${colors.yellowBold}⚠️ [PrivacyScrubber] Custom rules detected in privacyscrubber.json, but are ignored in the Free Tier.${colors.reset}\n${colors.cyan}👉 Set PRIVACYSCRUBBER_KEY to your PRO license key.${colors.reset}\n`);
4072
4574
  }