@telvok/librarian-mcp 2.0.0 → 2.3.0

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.
@@ -0,0 +1,20 @@
1
+ export interface SensitiveFinding {
2
+ entry: string;
3
+ file?: string;
4
+ matches: string[];
5
+ }
6
+ interface ScannableEntry {
7
+ title: string;
8
+ content: string;
9
+ intent?: string;
10
+ context?: string;
11
+ reasoning?: string;
12
+ example?: string;
13
+ originalPath?: string;
14
+ }
15
+ /**
16
+ * Scan entries for sensitive data patterns.
17
+ * Returns findings grouped by entry.
18
+ */
19
+ export declare function scanForSensitiveData(entries: ScannableEntry[]): SensitiveFinding[];
20
+ export {};
@@ -0,0 +1,56 @@
1
+ // ============================================================================
2
+ // Sensitive Data Scanner
3
+ // Shared module for scanning entries before they leave the user's machine.
4
+ // Used by: library_publish (mandatory), audit tool (on-demand)
5
+ // ============================================================================
6
+ const SENSITIVE_PATTERNS = [
7
+ // API keys and tokens
8
+ { pattern: /sk_(live|test)_[a-zA-Z0-9]{10,}/g, label: 'Stripe secret key' },
9
+ { pattern: /whsec_[a-zA-Z0-9]{10,}/g, label: 'Stripe webhook secret' },
10
+ { pattern: /tvk_[a-zA-Z0-9]{20,}/g, label: 'Telvok API key' },
11
+ { pattern: /ghp_[a-zA-Z0-9]{36}/g, label: 'GitHub personal access token' },
12
+ { pattern: /xoxb-[a-zA-Z0-9-]+/g, label: 'Slack bot token' },
13
+ { pattern: /eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}/g, label: 'JWT token' },
14
+ { pattern: /AKIA[A-Z0-9]{16}/g, label: 'AWS access key' },
15
+ { pattern: /npm_[a-zA-Z0-9]{36}/g, label: 'npm token' },
16
+ // Credentials in assignments
17
+ { pattern: /password\s*[:=]\s*['"][^'"]+['"]/gi, label: 'password value' },
18
+ { pattern: /secret\s*[:=]\s*['"][^'"]+['"]/gi, label: 'secret value' },
19
+ { pattern: /api[_-]?key\s*[:=]\s*['"][^'"]+['"]/gi, label: 'API key value' },
20
+ // Personal data
21
+ { pattern: /\b[a-zA-Z0-9._%+-]+@(?!example\.com)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g, label: 'email address' },
22
+ // Connection strings with credentials
23
+ { pattern: /:\/\/[^:]+:[^@]+@[^/\s]+/g, label: 'URL with embedded credentials' },
24
+ ];
25
+ /**
26
+ * Scan entries for sensitive data patterns.
27
+ * Returns findings grouped by entry.
28
+ */
29
+ export function scanForSensitiveData(entries) {
30
+ const findings = [];
31
+ for (const entry of entries) {
32
+ const textToScan = [
33
+ entry.title,
34
+ entry.content,
35
+ entry.intent,
36
+ entry.context,
37
+ entry.reasoning,
38
+ entry.example,
39
+ ].filter(Boolean).join('\n');
40
+ const matches = [];
41
+ for (const { pattern, label } of SENSITIVE_PATTERNS) {
42
+ pattern.lastIndex = 0;
43
+ if (pattern.test(textToScan)) {
44
+ matches.push(label);
45
+ }
46
+ }
47
+ if (matches.length > 0) {
48
+ findings.push({
49
+ entry: entry.title,
50
+ file: entry.originalPath,
51
+ matches,
52
+ });
53
+ }
54
+ }
55
+ return findings;
56
+ }
package/dist/server.js CHANGED
@@ -27,6 +27,7 @@ import { bountySubmitTool } from './tools/bounty-submit.js';
27
27
  import { myBountiesTool } from './tools/my-bounties.js';
28
28
  import { deleteTool } from './tools/delete.js';
29
29
  import { unsubscribeTool } from './tools/unsubscribe.js';
30
+ import { auditTool } from './tools/audit.js';
30
31
  const allTools = [
31
32
  // Core tools — local knowledge management
32
33
  { tool: briefTool, group: 'core' },
@@ -36,6 +37,7 @@ const allTools = [
36
37
  { tool: importMemoriesTool, group: 'core' },
37
38
  { tool: rebuildIndexTool, group: 'core' },
38
39
  { tool: deleteTool, group: 'core' },
40
+ { tool: auditTool, group: 'core' },
39
41
  // Marketplace tools — cloud features
40
42
  { tool: librarySearchTool, group: 'marketplace' },
41
43
  { tool: libraryBuyTool, group: 'marketplace' },
@@ -0,0 +1,27 @@
1
+ import { type SensitiveFinding } from '../library/sensitive-scanner.js';
2
+ interface AuditResult {
3
+ success: boolean;
4
+ message: string;
5
+ total_scanned: number;
6
+ findings: SensitiveFinding[];
7
+ clean: boolean;
8
+ }
9
+ export declare const auditTool: {
10
+ name: string;
11
+ title: string;
12
+ description: string;
13
+ inputSchema: {
14
+ type: "object";
15
+ properties: {
16
+ entries: {
17
+ type: string;
18
+ items: {
19
+ type: string;
20
+ };
21
+ description: string;
22
+ };
23
+ };
24
+ };
25
+ handler(args: unknown): Promise<AuditResult>;
26
+ };
27
+ export {};
@@ -0,0 +1,126 @@
1
+ // ============================================================================
2
+ // Audit Tool
3
+ // Scan local entries for sensitive data before publishing
4
+ // ============================================================================
5
+ import * as fs from 'fs/promises';
6
+ import * as path from 'path';
7
+ import { glob } from 'glob';
8
+ import matter from 'gray-matter';
9
+ import { getLibraryPath, getLocalPath } from '../library/storage.js';
10
+ import { scanForSensitiveData } from '../library/sensitive-scanner.js';
11
+ // ============================================================================
12
+ // Tool Definition
13
+ // ============================================================================
14
+ export const auditTool = {
15
+ name: 'audit',
16
+ title: 'Audit Entries',
17
+ description: `Scan local entries for sensitive data (API keys, passwords, emails, tokens, credentials).
18
+
19
+ USE THIS TOOL WHEN:
20
+ - Before publishing a book — catches leaks before they go public
21
+ - User says "audit", "check for secrets", or "scan my entries"
22
+ - After recording entries that involved credentials or auth work
23
+ - Proactively before any library_publish() call
24
+
25
+ Returns a list of entries with sensitive data findings, or confirms all clear.
26
+
27
+ TRIGGER PATTERNS:
28
+ - Before publishing → audit()
29
+ - "Check my entries for secrets" → audit()
30
+ - Scan specific entries → audit({ entries: ["file1.md", "file2.md"] })`,
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ entries: {
35
+ type: 'array',
36
+ items: { type: 'string' },
37
+ description: 'Specific entry filenames to audit (omit to scan all local/)',
38
+ },
39
+ },
40
+ },
41
+ async handler(args) {
42
+ const { entries: entryFilter } = (args || {});
43
+ const libraryPath = getLibraryPath();
44
+ const localPath = getLocalPath(libraryPath);
45
+ // Collect entries
46
+ const collectedEntries = [];
47
+ try {
48
+ const files = await glob(path.join(localPath, '**/*.md'), { nodir: true });
49
+ for (const filePath of files) {
50
+ const filename = path.basename(filePath);
51
+ if (entryFilter && entryFilter.length > 0) {
52
+ const matchesFilter = entryFilter.some(f => filename === f ||
53
+ filename === f + '.md' ||
54
+ filePath.endsWith(f) ||
55
+ filePath.endsWith(f + '.md'));
56
+ if (!matchesFilter)
57
+ continue;
58
+ }
59
+ try {
60
+ const content = await fs.readFile(filePath, 'utf-8');
61
+ const { data: frontmatter, content: body } = matter(content);
62
+ const trimmedBody = body.trim();
63
+ if (!trimmedBody)
64
+ continue;
65
+ let title = frontmatter.title;
66
+ if (!title) {
67
+ const headingMatch = trimmedBody.match(/^#\s+(.+)$/m);
68
+ title = headingMatch ? headingMatch[1].trim() : path.basename(filePath, '.md');
69
+ }
70
+ // Extract sections
71
+ const reasoningMatch = trimmedBody.match(/##\s*Reasoning\s*\n([\s\S]*?)(?=##|$)/i);
72
+ const exampleMatch = trimmedBody.match(/##\s*Example\s*\n([\s\S]*?)(?=##|$)/i);
73
+ collectedEntries.push({
74
+ title,
75
+ content: trimmedBody,
76
+ intent: frontmatter.intent || undefined,
77
+ context: frontmatter.context || undefined,
78
+ reasoning: reasoningMatch ? reasoningMatch[1].trim() : undefined,
79
+ example: exampleMatch ? exampleMatch[1].trim() : undefined,
80
+ originalPath: filePath,
81
+ });
82
+ }
83
+ catch {
84
+ // Skip unparseable files
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ return {
90
+ success: false,
91
+ message: 'No .librarian/local/ directory found.',
92
+ total_scanned: 0,
93
+ findings: [],
94
+ clean: false,
95
+ };
96
+ }
97
+ if (collectedEntries.length === 0) {
98
+ return {
99
+ success: true,
100
+ message: 'No entries found to audit.',
101
+ total_scanned: 0,
102
+ findings: [],
103
+ clean: true,
104
+ };
105
+ }
106
+ // Run scan
107
+ const findings = scanForSensitiveData(collectedEntries);
108
+ if (findings.length === 0) {
109
+ return {
110
+ success: true,
111
+ message: `✅ All clear — scanned ${collectedEntries.length} entries, no sensitive data found.`,
112
+ total_scanned: collectedEntries.length,
113
+ findings: [],
114
+ clean: true,
115
+ };
116
+ }
117
+ const warnings = findings.map(f => ` ⚠ ${f.entry}: ${f.matches.join(', ')}`).join('\n');
118
+ return {
119
+ success: true,
120
+ message: `⚠ Found sensitive data in ${findings.length} of ${collectedEntries.length} entries:\n${warnings}\n\nClean these up with record() or delete() before publishing.`,
121
+ total_scanned: collectedEntries.length,
122
+ findings,
123
+ clean: false,
124
+ };
125
+ },
126
+ };
@@ -1,6 +1,7 @@
1
1
  interface PublishResult {
2
2
  success: boolean;
3
3
  message: string;
4
+ publish_token?: string;
4
5
  book?: {
5
6
  id?: string;
6
7
  slug: string;
@@ -85,6 +86,10 @@ export declare const libraryPublishTool: {
85
86
  type: string;
86
87
  description: string;
87
88
  };
89
+ publish_token: {
90
+ type: string;
91
+ description: string;
92
+ };
88
93
  entries: {
89
94
  type: string;
90
95
  items: {
@@ -4,36 +4,41 @@
4
4
  // ============================================================================
5
5
  import * as fs from 'fs/promises';
6
6
  import * as path from 'path';
7
+ import * as crypto from 'crypto';
7
8
  import { glob } from 'glob';
8
9
  import matter from 'gray-matter';
9
10
  import { loadApiKey } from './auth.js';
10
11
  import { getLibraryPath, getLocalPath } from '../library/storage.js';
12
+ import { scanForSensitiveData } from '../library/sensitive-scanner.js';
11
13
  const TELVOK_API_URL = process.env.TELVOK_API_URL || 'https://telvok.com';
14
+ const TOKEN_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
15
+ let pendingPublish = null;
12
16
  // ============================================================================
13
17
  // Tool Definition
14
18
  // ============================================================================
15
19
  export const libraryPublishTool = {
16
20
  name: 'library_publish',
17
21
  title: 'Publish Book',
18
- description: `Publish local entries as a book on Telvok marketplace.
22
+ description: `Publish local entries as a book on Telvok library.
19
23
 
20
- USE THIS TOOL WHEN:
21
- - User wants to share/sell their recorded knowledge
22
- - User says "publish", "share", or "sell" their entries
23
- - Creating a book from .librarian/local/ entries
24
+ ⚠️ TWO-STEP PUBLISH FLOW (MANDATORY):
24
25
 
25
- ALWAYS use preview: true first to show what will be published.
26
+ Step 1: ALWAYS call with preview: true first. This shows what will be published
27
+ and returns a publish_token. Show the preview to the user and ASK FOR CONFIRMATION.
28
+
29
+ Step 2: ONLY after the user explicitly confirms, call again with the publish_token
30
+ from the preview response. Publishing WITHOUT a valid token will be rejected.
31
+
32
+ DO NOT skip the preview. DO NOT publish without user confirmation.
33
+ The tool will refuse to publish without a valid publish_token from a preview.
26
34
 
27
35
  TRIGGER PATTERNS:
28
36
  - "Publish my entries" → library_publish({ name: "...", pricing: { type: "open" }, preview: true })
29
- - "Sell my knowledge" → library_publish({ name: "...", pricing: { type: "one_time", price_cents: 500 }, preview: true })
30
- - After preview approval → add attestation and consumption, remove preview
31
-
32
- Required for actual publish: name, pricing, consumption, attestation (all true).
37
+ - User says "yes, publish it" → library_publish({ ..., publish_token: "<token from preview>" })
33
38
 
34
39
  Examples:
35
40
  - Preview: library_publish({ name: "My Book", pricing: { type: "open" }, preview: true })
36
- - Publish: library_publish({ name: "My Book", pricing: { type: "open" }, consumption: "download", attestation: { original_work: true, no_secrets: true, terms_accepted: true } })`,
41
+ - Publish: library_publish({ name: "My Book", pricing: { type: "open" }, consumption: "download", attestation: { original_work: true, no_secrets: true, terms_accepted: true }, publish_token: "abc123" })`,
37
42
  inputSchema: {
38
43
  type: 'object',
39
44
  properties: {
@@ -78,7 +83,11 @@ Examples:
78
83
  },
79
84
  preview: {
80
85
  type: 'boolean',
81
- description: 'If true, show what would be published without publishing',
86
+ description: 'If true, show what would be published without publishing. Returns a publish_token.',
87
+ },
88
+ publish_token: {
89
+ type: 'string',
90
+ description: 'Token from preview response. Required to actually publish. Single-use, expires in 5 minutes.',
82
91
  },
83
92
  entries: {
84
93
  type: 'array',
@@ -99,7 +108,7 @@ Examples:
99
108
  required: ['name', 'pricing'],
100
109
  },
101
110
  async handler(args) {
102
- const { name, description, pricing, consumption, attestation, preview, entries: entryFilter, tags, license } = args;
111
+ const { name, description, pricing, consumption, attestation, preview, publish_token, entries: entryFilter, tags, license } = args;
103
112
  // Validate name
104
113
  if (!name || typeof name !== 'string' || name.trim().length < 3) {
105
114
  throw new Error('Book name is required (minimum 3 characters)');
@@ -130,6 +139,24 @@ Examples:
130
139
  }
131
140
  // Collect entries from local/ (needed for preview and publish)
132
141
  const collectedEntries = await collectLocalEntries(entryFilter);
142
+ // Scan for sensitive data before publishing
143
+ const sensitiveFindings = scanForSensitiveData(collectedEntries);
144
+ if (sensitiveFindings.length > 0) {
145
+ const warnings = sensitiveFindings.map(f => ` ⚠ ${f.entry}: ${f.matches.join(', ')}`).join('\n');
146
+ if (preview) {
147
+ // In preview mode, show warnings but continue
148
+ return {
149
+ success: true,
150
+ preview: true,
151
+ message: `⚠ SENSITIVE DATA DETECTED in ${sensitiveFindings.length} entry(s):\n${warnings}\n\nReview these entries before publishing. Remove credentials, API keys, passwords, and personal data.`,
152
+ };
153
+ }
154
+ // In publish mode, block and require cleanup
155
+ return {
156
+ success: false,
157
+ message: `🚫 Publish blocked — sensitive data detected in ${sensitiveFindings.length} entry(s):\n${warnings}\n\nClean up these entries with record() or delete() before publishing. Use library_publish({ preview: true }) to re-check.`,
158
+ };
159
+ }
133
160
  if (collectedEntries.length === 0) {
134
161
  return {
135
162
  success: false,
@@ -140,12 +167,20 @@ Examples:
140
167
  const pricingDisplay = pricing.type === 'open'
141
168
  ? 'Free'
142
169
  : `$${((pricing.price_cents || 0) / 100).toFixed(2)}`;
143
- // Handle preview mode - return summary without publishing
170
+ // Handle preview mode - return summary with publish token
144
171
  if (preview) {
172
+ const token = crypto.randomBytes(16).toString('hex');
173
+ pendingPublish = {
174
+ token,
175
+ name: name.trim(),
176
+ entries_count: collectedEntries.length,
177
+ created: Date.now(),
178
+ };
145
179
  return {
146
180
  success: true,
147
181
  preview: true,
148
- message: `Preview of "${name.trim()}" - NOT published`,
182
+ message: `Preview of "${name.trim()}" - NOT published yet.\n\n⚠️ Show this to the user and ask for confirmation before publishing.`,
183
+ publish_token: token,
149
184
  summary: {
150
185
  name: name.trim(),
151
186
  pricing: { type: pricing.type, display: pricingDisplay },
@@ -155,9 +190,34 @@ Examples:
155
190
  file: path.basename(e.originalPath),
156
191
  })),
157
192
  },
158
- next_steps: 'To publish, add consumption type and attestation fields.',
193
+ next_steps: 'Show preview to user. After they confirm, call library_publish() again with the publish_token to publish.',
194
+ };
195
+ }
196
+ // ========================================================================
197
+ // PUBLISH TOKEN VALIDATION
198
+ // Cannot publish without a valid token from preview
199
+ // ========================================================================
200
+ if (!publish_token) {
201
+ return {
202
+ success: false,
203
+ message: '🚫 Publishing requires a publish_token from a preview.\n\nYou must call library_publish({ preview: true, ... }) first, show the preview to the user, get their confirmation, then call again with the publish_token.\n\nThis is a safety measure to prevent accidental publishing.',
204
+ };
205
+ }
206
+ if (!pendingPublish || pendingPublish.token !== publish_token) {
207
+ return {
208
+ success: false,
209
+ message: '🚫 Invalid or expired publish_token. Run a new preview first with library_publish({ preview: true, ... }).',
210
+ };
211
+ }
212
+ if (Date.now() - pendingPublish.created > TOKEN_EXPIRY_MS) {
213
+ pendingPublish = null;
214
+ return {
215
+ success: false,
216
+ message: '🚫 Publish token expired (5 minute limit). Run a new preview first.',
159
217
  };
160
218
  }
219
+ // Token is valid — consume it (single use)
220
+ pendingPublish = null;
161
221
  // Validate consumption type (required for actual publish)
162
222
  if (!consumption) {
163
223
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telvok/librarian-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.3.0",
4
4
  "description": "Knowledge capture MCP server - remember what you learn with AI",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",