claude-session-share 1.0.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.
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/dist/__tests__/e2e.test.js +532 -0
- package/dist/__tests__/gist-client.test.js +341 -0
- package/dist/__tests__/index.test.js +16 -0
- package/dist/__tests__/mcp-integration.test.js +403 -0
- package/dist/__tests__/path-encoding.test.js +77 -0
- package/dist/__tests__/pipeline.test.js +342 -0
- package/dist/__tests__/redactor.test.js +162 -0
- package/dist/__tests__/sanitizer.test.js +345 -0
- package/dist/__tests__/session-importer.test.js +216 -0
- package/dist/__tests__/session-reader.test.js +298 -0
- package/dist/__tests__/session-uploader.test.js +216 -0
- package/dist/__tests__/session-writer.test.js +286 -0
- package/dist/__tests__/uuid-mapper.test.js +249 -0
- package/dist/gist/client.js +199 -0
- package/dist/gist/types.js +7 -0
- package/dist/index.js +214 -0
- package/dist/sanitization/pipeline.js +48 -0
- package/dist/sanitization/redactor.js +48 -0
- package/dist/sanitization/sanitizer.js +87 -0
- package/dist/services/session-importer.js +88 -0
- package/dist/services/session-uploader.js +64 -0
- package/dist/session/finder.js +65 -0
- package/dist/session/metadata.js +55 -0
- package/dist/session/reader.js +101 -0
- package/dist/session/types.js +11 -0
- package/dist/session/writer.js +74 -0
- package/dist/utils/path-encoding.js +54 -0
- package/dist/utils/uuid-mapper.js +73 -0
- package/package.json +54 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Gist client for creating and managing secret gists
|
|
3
|
+
*
|
|
4
|
+
* Uses Octokit v5 with automatic rate limiting and retry handling.
|
|
5
|
+
* Requires GITHUB_TOKEN environment variable for authentication.
|
|
6
|
+
*/
|
|
7
|
+
import { Octokit } from 'octokit';
|
|
8
|
+
/**
|
|
9
|
+
* Error thrown when GITHUB_TOKEN is missing or invalid
|
|
10
|
+
*/
|
|
11
|
+
export class GistAuthError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'GistAuthError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Error thrown when Gist API operations fail
|
|
19
|
+
*/
|
|
20
|
+
export class GistApiError extends Error {
|
|
21
|
+
statusCode;
|
|
22
|
+
constructor(message, statusCode) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.statusCode = statusCode;
|
|
25
|
+
this.name = 'GistApiError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Authenticated GitHub Gist client
|
|
30
|
+
*
|
|
31
|
+
* Handles authentication, rate limiting, and error handling for GitHub Gist operations.
|
|
32
|
+
*/
|
|
33
|
+
export class GistClient {
|
|
34
|
+
octokit;
|
|
35
|
+
constructor() {
|
|
36
|
+
const token = process.env.GITHUB_TOKEN;
|
|
37
|
+
if (!token) {
|
|
38
|
+
throw new GistAuthError('GITHUB_TOKEN environment variable is required. ' +
|
|
39
|
+
'Create a personal access token at https://github.com/settings/tokens with "gist" scope.');
|
|
40
|
+
}
|
|
41
|
+
// Initialize Octokit with authentication and throttling
|
|
42
|
+
this.octokit = new Octokit({
|
|
43
|
+
auth: token,
|
|
44
|
+
throttle: {
|
|
45
|
+
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
|
46
|
+
// Retry up to 3 times on primary rate limits
|
|
47
|
+
console.warn(`Rate limit hit for ${options.method} ${options.url}. ` +
|
|
48
|
+
`Retrying after ${retryAfter} seconds (attempt ${retryCount + 1}/3)`);
|
|
49
|
+
return retryCount < 3;
|
|
50
|
+
},
|
|
51
|
+
onSecondaryRateLimit: (retryAfter, options, octokit) => {
|
|
52
|
+
// Always retry secondary rate limits (abuse detection)
|
|
53
|
+
console.warn(`Secondary rate limit hit for ${options.method} ${options.url}. ` +
|
|
54
|
+
`Retrying after ${retryAfter} seconds`);
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the authenticated Octokit instance
|
|
62
|
+
* @internal Used for testing
|
|
63
|
+
*/
|
|
64
|
+
getOctokit() {
|
|
65
|
+
return this.octokit;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Extract gist ID from a GitHub gist URL or return the ID if already in ID format
|
|
69
|
+
*
|
|
70
|
+
* @param gistIdOrUrl - Either a full gist URL (https://gist.github.com/username/abc123) or just the gist ID
|
|
71
|
+
* @returns The extracted gist ID
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* extractGistId('https://gist.github.com/user/abc123def456') // Returns 'abc123def456'
|
|
75
|
+
* extractGistId('abc123def456') // Returns 'abc123def456'
|
|
76
|
+
*/
|
|
77
|
+
extractGistId(gistIdOrUrl) {
|
|
78
|
+
// Remove trailing slash if present
|
|
79
|
+
const normalized = gistIdOrUrl.replace(/\/$/, '');
|
|
80
|
+
// If it's already just an ID (no slashes), return it
|
|
81
|
+
if (!normalized.includes('/')) {
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
// Extract ID from URL format: https://gist.github.com/username/abc123def456
|
|
85
|
+
// The ID is the last segment after the username
|
|
86
|
+
const segments = normalized.split('/');
|
|
87
|
+
const lastSegment = segments[segments.length - 1];
|
|
88
|
+
// Return the last segment (which should be the gist ID)
|
|
89
|
+
return lastSegment;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Fetch a gist by ID or URL
|
|
93
|
+
*
|
|
94
|
+
* @param gistIdOrUrl - Either a full gist URL or just the gist ID
|
|
95
|
+
* @returns Promise resolving to GistResponse with gist content
|
|
96
|
+
* @throws {GistAuthError} If token is invalid (401)
|
|
97
|
+
* @throws {GistApiError} If gist not found (404), private/deleted (403), or other errors
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const gist = await client.fetchGist('https://gist.github.com/user/abc123');
|
|
101
|
+
* const gist = await client.fetchGist('abc123');
|
|
102
|
+
*/
|
|
103
|
+
async fetchGist(gistIdOrUrl) {
|
|
104
|
+
const gistId = this.extractGistId(gistIdOrUrl);
|
|
105
|
+
try {
|
|
106
|
+
const response = await this.octokit.rest.gists.get({
|
|
107
|
+
gist_id: gistId,
|
|
108
|
+
});
|
|
109
|
+
// Map response to our GistResponse type
|
|
110
|
+
return {
|
|
111
|
+
id: response.data.id,
|
|
112
|
+
url: response.data.url,
|
|
113
|
+
html_url: response.data.html_url,
|
|
114
|
+
files: response.data.files,
|
|
115
|
+
public: response.data.public,
|
|
116
|
+
created_at: response.data.created_at,
|
|
117
|
+
updated_at: response.data.updated_at,
|
|
118
|
+
description: response.data.description || '',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Handle authentication errors
|
|
123
|
+
if (error.status === 401) {
|
|
124
|
+
throw new GistAuthError('Invalid GITHUB_TOKEN. Please check that your token is valid and has the "gist" scope. ' +
|
|
125
|
+
'Create a new token at https://github.com/settings/tokens');
|
|
126
|
+
}
|
|
127
|
+
// Handle not found errors
|
|
128
|
+
if (error.status === 404) {
|
|
129
|
+
throw new GistApiError(`Gist not found: ${gistId}. The gist may not exist or you may not have access to it.`, 404);
|
|
130
|
+
}
|
|
131
|
+
// Handle permission/private gist errors
|
|
132
|
+
if (error.status === 403) {
|
|
133
|
+
const message = error.message || 'Forbidden';
|
|
134
|
+
if (message.toLowerCase().includes('rate limit')) {
|
|
135
|
+
throw new GistApiError('GitHub API rate limit exceeded. Please wait and try again later.', 403);
|
|
136
|
+
}
|
|
137
|
+
throw new GistApiError(`Access denied to gist ${gistId}. The gist may be private or deleted. Ensure your GITHUB_TOKEN has the "gist" scope.`, 403);
|
|
138
|
+
}
|
|
139
|
+
// Handle other errors
|
|
140
|
+
throw new GistApiError(`Failed to fetch gist: ${error.message || 'Unknown error'}`, error.status);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Create a secret (unlisted) gist with the provided files
|
|
145
|
+
*
|
|
146
|
+
* @param description - Description of the gist
|
|
147
|
+
* @param files - Object mapping filenames to file content
|
|
148
|
+
* @returns Promise resolving to GistResponse with gist URL, ID, and files
|
|
149
|
+
* @throws {GistAuthError} If token is invalid (401)
|
|
150
|
+
* @throws {GistApiError} If API request fails (403, 422, or other errors)
|
|
151
|
+
*/
|
|
152
|
+
async createGist(description, files) {
|
|
153
|
+
try {
|
|
154
|
+
// Convert files object to Gist API format
|
|
155
|
+
const gistFiles = {};
|
|
156
|
+
for (const [filename, content] of Object.entries(files)) {
|
|
157
|
+
gistFiles[filename] = { content };
|
|
158
|
+
}
|
|
159
|
+
// Create secret gist (public: false makes it unlisted)
|
|
160
|
+
const response = await this.octokit.rest.gists.create({
|
|
161
|
+
description,
|
|
162
|
+
public: false,
|
|
163
|
+
files: gistFiles,
|
|
164
|
+
});
|
|
165
|
+
// Map response to our GistResponse type
|
|
166
|
+
return {
|
|
167
|
+
id: response.data.id,
|
|
168
|
+
url: response.data.url,
|
|
169
|
+
html_url: response.data.html_url,
|
|
170
|
+
files: response.data.files,
|
|
171
|
+
public: response.data.public,
|
|
172
|
+
created_at: response.data.created_at,
|
|
173
|
+
updated_at: response.data.updated_at,
|
|
174
|
+
description: response.data.description || '',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
// Handle authentication errors
|
|
179
|
+
if (error.status === 401) {
|
|
180
|
+
throw new GistAuthError('Invalid GITHUB_TOKEN. Please check that your token is valid and has the "gist" scope. ' +
|
|
181
|
+
'Create a new token at https://github.com/settings/tokens');
|
|
182
|
+
}
|
|
183
|
+
// Handle permission/rate limit errors
|
|
184
|
+
if (error.status === 403) {
|
|
185
|
+
const message = error.message || 'Forbidden';
|
|
186
|
+
if (message.toLowerCase().includes('rate limit')) {
|
|
187
|
+
throw new GistApiError('GitHub API rate limit exceeded. Please wait and try again later.', 403);
|
|
188
|
+
}
|
|
189
|
+
throw new GistApiError('Permission denied. Ensure your GITHUB_TOKEN has the "gist" scope.', 403);
|
|
190
|
+
}
|
|
191
|
+
// Handle validation errors
|
|
192
|
+
if (error.status === 422) {
|
|
193
|
+
throw new GistApiError(`Invalid gist data: ${error.message || 'Validation failed'}`, 422);
|
|
194
|
+
}
|
|
195
|
+
// Handle other errors
|
|
196
|
+
throw new GistApiError(`Failed to create gist: ${error.message || 'Unknown error'}`, error.status);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { uploadSession } from "./services/session-uploader.js";
|
|
6
|
+
import { importSession } from "./services/session-importer.js";
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { readdir, stat } from 'fs/promises';
|
|
10
|
+
/**
|
|
11
|
+
* MCP Server for Claude Code session sharing
|
|
12
|
+
*
|
|
13
|
+
* This server provides tools for exporting and importing Claude Code sessions,
|
|
14
|
+
* enabling collaboration through shareable links.
|
|
15
|
+
*/
|
|
16
|
+
// Create the MCP server instance
|
|
17
|
+
const server = new Server({
|
|
18
|
+
name: "claude-session-share",
|
|
19
|
+
version: "0.1.0",
|
|
20
|
+
}, {
|
|
21
|
+
capabilities: {
|
|
22
|
+
tools: {},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Find the most recent session file
|
|
27
|
+
* Searches ~/.claude/projects/ for all session files and returns the most recently modified one
|
|
28
|
+
*/
|
|
29
|
+
async function findMostRecentSession() {
|
|
30
|
+
const projectsDir = join(homedir(), '.claude', 'projects');
|
|
31
|
+
try {
|
|
32
|
+
// Get all subdirectories in ~/.claude/projects/
|
|
33
|
+
const entries = await readdir(projectsDir, { withFileTypes: true });
|
|
34
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
35
|
+
let mostRecentFile = null;
|
|
36
|
+
let mostRecentTime = 0;
|
|
37
|
+
// Search each project directory for session files
|
|
38
|
+
for (const dir of dirs) {
|
|
39
|
+
const dirPath = join(projectsDir, dir);
|
|
40
|
+
try {
|
|
41
|
+
const files = await readdir(dirPath);
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
if (file.endsWith('.jsonl')) {
|
|
44
|
+
const filePath = join(dirPath, file);
|
|
45
|
+
const stats = await stat(filePath);
|
|
46
|
+
if (stats.mtimeMs > mostRecentTime) {
|
|
47
|
+
mostRecentTime = stats.mtimeMs;
|
|
48
|
+
mostRecentFile = filePath;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
// Skip directories we can't read
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return mostRecentFile;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
throw new Error(`Failed to find sessions: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* List available tools
|
|
66
|
+
*/
|
|
67
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
68
|
+
return {
|
|
69
|
+
tools: [
|
|
70
|
+
{
|
|
71
|
+
name: "share_session",
|
|
72
|
+
description: "Share a Claude Code session via GitHub Gist. Creates a sanitized, shareable link to the conversation.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
sessionPath: {
|
|
77
|
+
type: "string",
|
|
78
|
+
description: "Optional path to session file. If not provided, shares the most recent session.",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "import_session",
|
|
85
|
+
description: "Import a shared Claude Code session from GitHub Gist URL or ID. Creates local resumable session in ~/.claude/projects/",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
gistUrl: {
|
|
90
|
+
type: "string",
|
|
91
|
+
description: "GitHub Gist URL (https://gist.github.com/user/id) or bare gist ID",
|
|
92
|
+
},
|
|
93
|
+
projectPath: {
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "Local project directory path where session will be imported (e.g., /Users/name/project)",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
required: ["gistUrl", "projectPath"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* Handle tool execution
|
|
106
|
+
*/
|
|
107
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
108
|
+
if (request.params.name === "share_session") {
|
|
109
|
+
try {
|
|
110
|
+
const sessionPath = request.params.arguments?.sessionPath;
|
|
111
|
+
// If no sessionPath provided, find most recent session
|
|
112
|
+
const pathToShare = sessionPath || await findMostRecentSession();
|
|
113
|
+
if (!pathToShare) {
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: "Error: No session files found. Please provide a session path or ensure you have Claude Code sessions in ~/.claude/projects/",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
isError: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Upload session and get gist URL
|
|
125
|
+
const gistUrl = await uploadSession(pathToShare);
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "text",
|
|
130
|
+
text: `Successfully shared session!\n\nGist URL: ${gistUrl}\n\nYou can share this URL with others to give them access to this conversation.`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
137
|
+
return {
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: "text",
|
|
141
|
+
text: `Failed to share session: ${errorMessage}`,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
isError: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (request.params.name === "import_session") {
|
|
149
|
+
try {
|
|
150
|
+
const gistUrl = request.params.arguments?.gistUrl;
|
|
151
|
+
const projectPath = request.params.arguments?.projectPath;
|
|
152
|
+
// Validate inputs
|
|
153
|
+
if (!gistUrl || typeof gistUrl !== 'string' || gistUrl.trim() === '') {
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: "Error: gistUrl is required and must be a non-empty string",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
isError: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!projectPath || typeof projectPath !== 'string' || projectPath.trim() === '') {
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: "Error: projectPath is required and must be a non-empty string",
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
isError: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// Import session
|
|
176
|
+
const result = await importSession(gistUrl, projectPath);
|
|
177
|
+
return {
|
|
178
|
+
content: [
|
|
179
|
+
{
|
|
180
|
+
type: "text",
|
|
181
|
+
text: `Session imported successfully!\n\nSession ID: ${result.sessionId}\nMessages: ${result.messageCount}\nLocation: ${result.sessionPath}\n\nUse 'claude --resume' to see imported session.`,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
188
|
+
return {
|
|
189
|
+
content: [
|
|
190
|
+
{
|
|
191
|
+
type: "text",
|
|
192
|
+
text: `Import failed: ${errorMessage}`,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
isError: true,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
200
|
+
});
|
|
201
|
+
/**
|
|
202
|
+
* Start the server with stdio transport
|
|
203
|
+
* This allows the MCP server to communicate via standard input/output
|
|
204
|
+
*/
|
|
205
|
+
async function main() {
|
|
206
|
+
const transport = new StdioServerTransport();
|
|
207
|
+
await server.connect(transport);
|
|
208
|
+
// Server is now running and ready to handle MCP protocol messages
|
|
209
|
+
console.error("Claude Session Share MCP server running on stdio");
|
|
210
|
+
}
|
|
211
|
+
main().catch((error) => {
|
|
212
|
+
console.error("Fatal error in main():", error);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session sanitization pipeline
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates full session sanitization:
|
|
5
|
+
* - Maps over all messages in session
|
|
6
|
+
* - Applies appropriate sanitization based on message type
|
|
7
|
+
* - Preserves message order and structure
|
|
8
|
+
*/
|
|
9
|
+
import { sanitizeAssistantMessage, sanitizeUserMessage, sanitizeFileHistorySnapshot, } from './sanitizer.js';
|
|
10
|
+
/**
|
|
11
|
+
* Sanitize entire session by processing all messages
|
|
12
|
+
*
|
|
13
|
+
* Uses discriminated union type guards to apply correct sanitization
|
|
14
|
+
* Returns new array (immutable transformation)
|
|
15
|
+
*/
|
|
16
|
+
export function sanitizeSession(messages, basePath) {
|
|
17
|
+
return messages.map((msg) => {
|
|
18
|
+
switch (msg.type) {
|
|
19
|
+
case 'assistant':
|
|
20
|
+
return sanitizeAssistantMessage(msg, basePath);
|
|
21
|
+
case 'user':
|
|
22
|
+
return sanitizeUserMessage(msg, basePath);
|
|
23
|
+
case 'file-history-snapshot':
|
|
24
|
+
return sanitizeFileHistorySnapshot(msg, basePath);
|
|
25
|
+
default:
|
|
26
|
+
// Exhaustiveness check - TypeScript ensures all cases covered
|
|
27
|
+
const _exhaustive = msg;
|
|
28
|
+
return _exhaustive;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Infer base path from session messages
|
|
34
|
+
*
|
|
35
|
+
* Extracts the working directory from first user message with cwd
|
|
36
|
+
* Returns empty string if no cwd found
|
|
37
|
+
*/
|
|
38
|
+
export function inferBasePath(messages) {
|
|
39
|
+
for (const msg of messages) {
|
|
40
|
+
if (msg.type === 'user') {
|
|
41
|
+
const userMsg = msg;
|
|
42
|
+
if (userMsg.cwd) {
|
|
43
|
+
return userMsg.cwd;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction utilities for tool results
|
|
3
|
+
*
|
|
4
|
+
* Detects and redacts common secret patterns:
|
|
5
|
+
* - API keys, tokens, access keys
|
|
6
|
+
* - Environment variable values
|
|
7
|
+
* - GitHub tokens
|
|
8
|
+
* - AWS credentials
|
|
9
|
+
* - Long base64/hex strings
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Pattern-based secret detection and redaction
|
|
13
|
+
*
|
|
14
|
+
* Philosophy: Better to redact too much (false positive) than leak secrets (false negative)
|
|
15
|
+
*/
|
|
16
|
+
export function redactSecrets(content) {
|
|
17
|
+
if (!content) {
|
|
18
|
+
return content;
|
|
19
|
+
}
|
|
20
|
+
let redacted = content;
|
|
21
|
+
// Pattern 1: API keys in JSON/object notation
|
|
22
|
+
// Matches: "api_key": "value", "apiKey": "value", "API_KEY": "value"
|
|
23
|
+
redacted = redacted.replace(/(["']?(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|access[_-]?token|auth[_-]?token|bearer[_-]?token)["']?\s*[=:]\s*)["']([^"'\s]{8,})["']/gi, '$1"[REDACTED]"');
|
|
24
|
+
// Pattern 2: Environment variable format (KEY=value)
|
|
25
|
+
// Matches: API_KEY=abc123, SECRET_TOKEN=xyz789
|
|
26
|
+
redacted = redacted.replace(/\b([A-Z_]+(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z_]*)\s*=\s*([^\s"']{8,})/g, '$1=[REDACTED]');
|
|
27
|
+
// Pattern 3: GitHub tokens
|
|
28
|
+
// Matches: ghp_, ghs_, github_pat_
|
|
29
|
+
redacted = redacted.replace(/\b(ghp_|ghs_|github_pat_)[a-zA-Z0-9]{30,}/g, '[REDACTED]');
|
|
30
|
+
// Pattern 4: AWS access keys
|
|
31
|
+
// Matches: AKIA..., aws_secret_access_key with long value
|
|
32
|
+
redacted = redacted.replace(/\bAKIA[A-Z0-9]{16}\b/g, '[REDACTED]');
|
|
33
|
+
redacted = redacted.replace(/(aws[_-]?secret[_-]?access[_-]?key\s*[=:]\s*)["']?([^\s"']{20,})["']?/gi, '$1[REDACTED]');
|
|
34
|
+
// Pattern 5: Generic long strings that look like secrets
|
|
35
|
+
// Base64-like strings (alphanumeric + / + =, 32+ chars)
|
|
36
|
+
// Hex strings (32+ hex chars)
|
|
37
|
+
// Only in value positions (after = or :)
|
|
38
|
+
redacted = redacted.replace(/([=:]\s*)["']?([A-Za-z0-9+/]{32,}={0,2})["']?/g, (match, prefix, value) => {
|
|
39
|
+
// Avoid redacting things that look like file paths or normal text
|
|
40
|
+
if (value.includes('/') && value.split('/').length > 2) {
|
|
41
|
+
return match; // Likely a file path
|
|
42
|
+
}
|
|
43
|
+
return prefix + '[REDACTED]';
|
|
44
|
+
});
|
|
45
|
+
// Pattern 6: Private keys (BEGIN PRIVATE KEY)
|
|
46
|
+
redacted = redacted.replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
|
|
47
|
+
return redacted;
|
|
48
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session sanitization utilities for privacy protection
|
|
3
|
+
*
|
|
4
|
+
* Removes sensitive data before sharing:
|
|
5
|
+
* - Strips thinking blocks from assistant messages
|
|
6
|
+
* - Sanitizes absolute paths to relative paths
|
|
7
|
+
* - Immutable transformations (returns new objects)
|
|
8
|
+
*/
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { redactSecrets } from './redactor.js';
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize assistant message by stripping thinking and sanitizing paths in tool results
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeAssistantMessage(msg, basePath) {
|
|
15
|
+
return {
|
|
16
|
+
...msg,
|
|
17
|
+
snapshot: {
|
|
18
|
+
thinking: null, // Strip thinking block
|
|
19
|
+
messages: msg.snapshot.messages.map((m) => ({
|
|
20
|
+
...m,
|
|
21
|
+
content: redactSecrets(sanitizePathsInContent(m.content, basePath)),
|
|
22
|
+
})),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Sanitize user message by converting absolute cwd to relative path
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeUserMessage(msg, basePath) {
|
|
30
|
+
return {
|
|
31
|
+
...msg,
|
|
32
|
+
cwd: sanitizePath(msg.cwd, basePath),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Sanitize file history snapshot by converting absolute paths to relative
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeFileHistorySnapshot(msg, basePath) {
|
|
39
|
+
return {
|
|
40
|
+
...msg,
|
|
41
|
+
snapshot: {
|
|
42
|
+
...msg.snapshot,
|
|
43
|
+
files: msg.snapshot.files.map((file) => ({
|
|
44
|
+
...file,
|
|
45
|
+
path: sanitizePath(file.path, basePath),
|
|
46
|
+
})),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Sanitize a single path: convert absolute to relative if within basePath
|
|
52
|
+
*/
|
|
53
|
+
function sanitizePath(absolutePath, basePath) {
|
|
54
|
+
if (!basePath || !absolutePath) {
|
|
55
|
+
return absolutePath;
|
|
56
|
+
}
|
|
57
|
+
// Normalize paths for comparison
|
|
58
|
+
const normalizedBase = path.resolve(basePath);
|
|
59
|
+
const normalizedPath = path.resolve(absolutePath);
|
|
60
|
+
// Check if path is within basePath
|
|
61
|
+
if (normalizedPath.startsWith(normalizedBase)) {
|
|
62
|
+
const relativePath = path.relative(normalizedBase, normalizedPath);
|
|
63
|
+
// Avoid returning empty string for basePath itself
|
|
64
|
+
return relativePath || '.';
|
|
65
|
+
}
|
|
66
|
+
// External path - return as-is
|
|
67
|
+
return absolutePath;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Sanitize paths found in text content (tool results, etc.)
|
|
71
|
+
*/
|
|
72
|
+
function sanitizePathsInContent(content, basePath) {
|
|
73
|
+
if (!basePath || !content) {
|
|
74
|
+
return content;
|
|
75
|
+
}
|
|
76
|
+
// Replace absolute paths with relative ones
|
|
77
|
+
// Match file paths (must be platform-aware)
|
|
78
|
+
const normalizedBase = path.resolve(basePath);
|
|
79
|
+
// Create regex that matches the base path
|
|
80
|
+
// Escape special regex characters in path
|
|
81
|
+
const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
// Match absolute paths starting with base (both forward and backslashes)
|
|
83
|
+
const pathRegex = new RegExp(escapedBase.replace(/\\/g, '[\\\\/]') + '[\\\\/][^\\s"\'<>|]*', 'g');
|
|
84
|
+
return content.replace(pathRegex, (match) => {
|
|
85
|
+
return sanitizePath(match, basePath);
|
|
86
|
+
});
|
|
87
|
+
}
|