Skip to content

Commit c703851

Browse files
committed
chore: fix bugs
1 parent 6840d6c commit c703851

File tree

10 files changed

+246
-97
lines changed

10 files changed

+246
-97
lines changed

apple/Clarissa/Sources/App/ClarissaApp.swift

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
2424

2525
#if os(iOS)
2626
import BackgroundTasks
27-
import CarPlay
2827
import UIKit
2928

3029
/// Background task identifier for memory sync
3130
private let backgroundMemorySyncTaskId = "dev.rye.Clarissa.memorySync"
3231

33-
/// App delegate to handle CarPlay scene configuration and background tasks
32+
/// App delegate to handle background tasks
3433
final class AppDelegate: NSObject, UIApplicationDelegate {
3534
func application(
3635
_ application: UIApplication,
@@ -41,28 +40,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
4140
return true
4241
}
4342

44-
func application(
45-
_ application: UIApplication,
46-
configurationForConnecting connectingSceneSession: UISceneSession,
47-
options: UIScene.ConnectionOptions
48-
) -> UISceneConfiguration {
49-
// Check if this is a CarPlay scene
50-
if connectingSceneSession.role == .carTemplateApplication {
51-
let config = UISceneConfiguration(
52-
name: "CarPlay Configuration",
53-
sessionRole: .carTemplateApplication
54-
)
55-
config.delegateClass = CarPlaySceneDelegate.self
56-
return config
57-
}
58-
59-
// Default configuration - SwiftUI handles the main app scene via WindowGroup
60-
return UISceneConfiguration(
61-
name: "Default Configuration",
62-
sessionRole: connectingSceneSession.role
63-
)
64-
}
65-
6643
// MARK: - Background Tasks
6744

6845
private func registerBackgroundTasks() {

src/context/file-references.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* @package.json:1-50 -> Lines 1-50 only
1212
*/
1313

14-
import { resolve, isAbsolute } from "path";
14+
import { validatePathWithinBase } from "../tools/security.ts";
1515

1616
/**
1717
* Result of expanding file references
@@ -111,10 +111,11 @@ export async function expandFileReferences(
111111

112112
const { path: filePath, startLine, endLine } = parseReference(reference);
113113

114-
// Resolve path
115-
const absolutePath = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
116-
117114
try {
115+
// Security: validate path is within the working directory to prevent path traversal
116+
// This blocks attempts like @/etc/passwd or @../../../etc/passwd
117+
const absolutePath = validatePathWithinBase(filePath, cwd);
118+
118119
const content = await readFileContents(absolutePath, startLine, endLine);
119120
const lineInfo = startLine ? `:${startLine}${endLine && endLine !== startLine ? `-${endLine}` : ""}` : "";
120121

src/llm/context.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -155,28 +155,25 @@ class ContextManager {
155155
result.push(...systemMessages);
156156
totalTokens = this.estimateConversationTokens(systemMessages);
157157

158-
// Group messages into atomic units (user, assistant+tool_results, etc.)
159-
// Tool results must stay with their corresponding assistant message
158+
// Group messages into atomic units that must stay together:
159+
// - User message starts a new group
160+
// - Assistant messages and tool results stay with their preceding user message
161+
// This ensures tool_calls and their results are never separated
160162
const messageGroups: Message[][] = [];
161163
let currentGroup: Message[] = [];
162164

163165
for (const msg of nonSystemMessages) {
164166
if (msg.role === "user") {
165-
// User messages start a new group
167+
// User messages always start a new group
166168
if (currentGroup.length > 0) {
167169
messageGroups.push(currentGroup);
168170
}
169171
currentGroup = [msg];
170-
} else if (msg.role === "assistant") {
171-
// Assistant messages start a new group (but include in current if empty)
172-
if (currentGroup.length > 0 && currentGroup[0]?.role !== "user") {
173-
messageGroups.push(currentGroup);
174-
currentGroup = [msg];
175-
} else {
176-
currentGroup.push(msg);
177-
}
178-
} else if (msg.role === "tool") {
179-
// Tool results must stay with their assistant message
172+
} else if (currentGroup.length === 0) {
173+
// Edge case: assistant/tool message without preceding user message
174+
currentGroup = [msg];
175+
} else {
176+
// Assistant and tool messages stay with current group
180177
currentGroup.push(msg);
181178
}
182179
}

src/llm/usage.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,39 @@ class UsageTracker {
7575
}
7676

7777
/**
78-
* Estimate tokens from text (rough approximation)
79-
* ~4 characters per token for English text
78+
* Estimate tokens from text using improved heuristics.
79+
* Based on analysis of BPE tokenization patterns:
80+
* - Common English words: ~1 token per word
81+
* - Numbers and punctuation: often separate tokens
82+
* - Code: more tokens due to identifiers and symbols
83+
* - Whitespace: typically not counted
8084
*/
8185
estimateTokens(text: string): number {
82-
return Math.ceil(text.length / 4);
86+
if (!text) return 0;
87+
88+
// Split into words (tokens often align with word boundaries)
89+
const words = text.split(/\s+/).filter((w) => w.length > 0);
90+
91+
// Base: count words
92+
let tokens = words.length;
93+
94+
// Add tokens for long words (they get split by BPE)
95+
// Average token length is ~4 chars, so words > 8 chars likely become 2+ tokens
96+
for (const word of words) {
97+
if (word.length > 8) {
98+
tokens += Math.floor(word.length / 6);
99+
}
100+
}
101+
102+
// Add tokens for punctuation and special characters
103+
// These often become separate tokens
104+
const specialChars = text.match(/[^\w\s]/g);
105+
if (specialChars) {
106+
tokens += Math.ceil(specialChars.length * 0.5);
107+
}
108+
109+
// Add overhead for message structure (~4 tokens per message for role, separators)
110+
return Math.max(1, tokens);
83111
}
84112

85113
/**

src/mcp/client.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
54
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
65
import type { Tool } from "../tools/base.ts";
76
import { z } from "zod";
@@ -111,6 +110,28 @@ export interface MCPServerSseConfigInternal {
111110

112111
export type MCPServerConfig = MCPServerStdioConfig | MCPServerSseConfigInternal;
113112

113+
/**
114+
* Maximum length for MCP tool results to prevent context overflow
115+
*/
116+
const MAX_RESULT_LENGTH = 50000;
117+
118+
/**
119+
* Sanitize MCP tool result to prevent prompt injection and limit size
120+
* MCP servers are external and untrusted, so we need to be careful with their output
121+
*/
122+
function sanitizeMcpResult(result: string, serverName: string, toolName: string): string {
123+
let sanitized = result;
124+
125+
// Truncate if too long
126+
if (sanitized.length > MAX_RESULT_LENGTH) {
127+
sanitized = sanitized.slice(0, MAX_RESULT_LENGTH) + `\n[Truncated - result exceeded ${MAX_RESULT_LENGTH} characters]`;
128+
}
129+
130+
// Wrap in clear delimiters to indicate this is external tool output
131+
// This helps the model understand the boundary of untrusted content
132+
return `<mcp_result server="${serverName}" tool="${toolName}">\n${sanitized}\n</mcp_result>`;
133+
}
134+
114135
interface MCPConnection {
115136
client: Client;
116137
transport: Transport;
@@ -123,6 +144,7 @@ interface MCPConnection {
123144
*/
124145
class MCPClientManager {
125146
private connections: Map<string, MCPConnection> = new Map();
147+
private cleanupHandlersRegistered = false;
126148

127149
/**
128150
* Connect to an MCP server (stdio or SSE transport)
@@ -136,18 +158,14 @@ class MCPClientManager {
136158

137159
if (config.transport === "sse") {
138160
// Remote HTTP/SSE server
139-
// Try StreamableHTTPClientTransport first, fallback to SSEClientTransport for legacy servers
161+
// Use StreamableHTTPClientTransport (modern MCP transport)
162+
// Note: SSEClientTransport is available for legacy servers but requires explicit config
140163
const url = new URL(config.url);
141164
const requestInit: RequestInit = config.headers
142165
? { headers: config.headers }
143166
: {};
144167

145-
try {
146-
transport = new StreamableHTTPClientTransport(url, { requestInit });
147-
} catch {
148-
// Fallback to legacy SSE transport
149-
transport = new SSEClientTransport(url, { requestInit });
150-
}
168+
transport = new StreamableHTTPClientTransport(url, { requestInit });
151169
} else {
152170
// Local stdio process (default)
153171
transport = new StdioClientTransport({
@@ -199,16 +217,20 @@ class MCPClientManager {
199217
});
200218

201219
// Handle different result types
220+
let rawResult: string;
202221
if (result.content && Array.isArray(result.content)) {
203-
return result.content
222+
rawResult = result.content
204223
.map((c) => {
205224
if (c.type === "text") return c.text;
206225
return JSON.stringify(c);
207226
})
208227
.join("\n");
228+
} else {
229+
rawResult = JSON.stringify(result);
209230
}
210231

211-
return JSON.stringify(result);
232+
// Sanitize the result to prevent prompt injection and limit size
233+
return sanitizeMcpResult(rawResult, serverName, mcpTool.name);
212234
},
213235
};
214236
}
@@ -326,8 +348,13 @@ class MCPClientManager {
326348

327349
/**
328350
* Register cleanup handlers for process exit
351+
* Only registers once to prevent memory leaks from duplicate listeners
329352
*/
330353
registerCleanupHandlers(): void {
354+
// Prevent registering duplicate handlers (memory leak)
355+
if (this.cleanupHandlersRegistered) return;
356+
this.cleanupHandlersRegistered = true;
357+
331358
let isCleaningUp = false;
332359

333360
const cleanup = async (): Promise<void> => {

src/session/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { join } from "path";
22
import { homedir } from "os";
3-
import { mkdir, readdir, rm } from "fs/promises";
3+
import { mkdir, readdir, rm, rename } from "fs/promises";
44
import { z } from "zod";
55
import type { Message } from "../llm/types.ts";
66

@@ -97,7 +97,8 @@ class SessionManager {
9797
}
9898

9999
/**
100-
* Save current session to disk
100+
* Save current session to disk using atomic write
101+
* Writes to temp file first, then renames to prevent corruption from concurrent saves
101102
*/
102103
async save(): Promise<void> {
103104
if (!this.currentSession) return;
@@ -106,7 +107,11 @@ class SessionManager {
106107
this.currentSession.updatedAt = new Date().toISOString();
107108

108109
const path = this.getPath(this.currentSession.id);
109-
await Bun.write(path, JSON.stringify(this.currentSession, null, 2));
110+
const tempPath = `${path}.tmp.${Date.now()}`;
111+
112+
// Write to temp file first, then atomically rename
113+
await Bun.write(tempPath, JSON.stringify(this.currentSession, null, 2));
114+
await rename(tempPath, path);
110115
}
111116

112117
/**

src/tools/bash.ts

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,75 @@ import { defineTool } from "./base.ts";
55
* Dangerous command patterns that should be blocked for safety.
66
* These patterns match commands that could cause severe system damage.
77
*/
8-
const DANGEROUS_PATTERNS = [
9-
// Recursive deletion of root or important directories
10-
/rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?[\/~]\s*$/i,
11-
/rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?[\/~]\s*$/i,
12-
/rm\s+-rf\s+\/\s*$/i,
13-
/rm\s+-rf\s+\/\*/i,
8+
const DANGEROUS_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
9+
// Recursive deletion of root, home, or current directory
10+
// Matches: rm -rf /, rm -rf ~, rm -rf ./, rm -rf ., etc.
11+
{
12+
pattern: /rm\s+(-[a-zA-Z]*\s+)*["']?([/~]|\.\.?\/?)["']?\s*$/i,
13+
reason: "Recursive deletion of root, home, or current directory",
14+
},
15+
{
16+
pattern: /rm\s+(-[a-zA-Z]*\s+)*["']?\/\*["']?/i,
17+
reason: "Recursive deletion of root contents",
18+
},
19+
// rm -rf * or rm -rf ./* (deletes everything in current directory)
20+
// Matches rm with -r flag (recursive) followed by wildcard
21+
{
22+
pattern: /rm\s+(-\w+\s+)*["']?\*["']?\s*$/i,
23+
reason: "Deletion with wildcard - potentially dangerous",
24+
},
1425
// Fork bomb patterns
15-
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;?\s*:/,
26+
{
27+
pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;?\s*:/,
28+
reason: "Fork bomb detected",
29+
},
1630
// Overwriting boot records or critical system files
17-
/>\s*\/dev\/sd[a-z]/i,
18-
/dd\s+.*of=\/dev\/sd[a-z]/i,
19-
/mkfs\s+.*\/dev\/sd[a-z]/i,
20-
// Chmod 777 on root
21-
/chmod\s+(-[a-zA-Z]*R[a-zA-Z]*\s+)?777\s+\/\s*$/i,
22-
// Dangerous redirects
23-
/>\s*\/dev\/null\s*2>&1\s*<\s*\/dev\/null/,
31+
{
32+
pattern: />\s*\/dev\/sd[a-z]/i,
33+
reason: "Writing to block device",
34+
},
35+
{
36+
pattern: /dd\s+.*of=\/dev\/sd[a-z]/i,
37+
reason: "dd to block device",
38+
},
39+
{
40+
pattern: /mkfs\s+.*\/dev\/sd[a-z]/i,
41+
reason: "Formatting block device",
42+
},
43+
// Chmod 777 on root or recursive on sensitive paths
44+
{
45+
pattern: /chmod\s+(-[a-zA-Z]*\s+)*777\s+["']?[/~]["']?\s*$/i,
46+
reason: "chmod 777 on root or home directory",
47+
},
48+
// Dangerous redirects that could hang the shell
49+
{
50+
pattern: />\s*\/dev\/null\s*2>&1\s*<\s*\/dev\/null/,
51+
reason: "Dangerous redirect pattern",
52+
},
2453
// Kill all processes
25-
/kill\s+-9\s+-1/,
26-
/killall\s+-9\s+/,
54+
{
55+
pattern: /kill\s+-9\s+-1/,
56+
reason: "Killing all processes",
57+
},
58+
{
59+
pattern: /killall\s+-9\s+/,
60+
reason: "Killing all processes by name",
61+
},
62+
// Prevent sudo with dangerous commands
63+
{
64+
pattern: /sudo\s+rm\s+(-[a-zA-Z]*\s+)*["']?[/~]["']?\s*$/i,
65+
reason: "sudo rm on root or home directory",
66+
},
67+
// Prevent overwriting /etc/passwd, /etc/shadow, etc.
68+
{
69+
pattern: />\s*\/etc\/(passwd|shadow|sudoers)/i,
70+
reason: "Overwriting critical system file",
71+
},
72+
// Prevent writing to /boot or /sys
73+
{
74+
pattern: />\s*\/(boot|sys)\//i,
75+
reason: "Writing to critical system directory",
76+
},
2777
];
2878

2979
/**
@@ -32,11 +82,11 @@ const DANGEROUS_PATTERNS = [
3282
function isDangerousCommand(command: string): { dangerous: boolean; reason?: string } {
3383
const trimmed = command.trim();
3484

35-
for (const pattern of DANGEROUS_PATTERNS) {
85+
for (const { pattern, reason } of DANGEROUS_PATTERNS) {
3686
if (pattern.test(trimmed)) {
3787
return {
3888
dangerous: true,
39-
reason: `Command matches dangerous pattern: ${pattern.toString()}`,
89+
reason,
4090
};
4191
}
4292
}

0 commit comments

Comments
 (0)