forkoff 1.0.17 → 1.0.19

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.
Files changed (158) hide show
  1. package/LICENSE +11 -7
  2. package/README.md +77 -118
  3. package/dist/approval.d.ts +1 -0
  4. package/dist/approval.js +9 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +62 -16
  7. package/dist/crypto/e2eeManager.d.ts +49 -52
  8. package/dist/crypto/e2eeManager.js +256 -181
  9. package/dist/crypto/encryption.d.ts +8 -10
  10. package/dist/crypto/encryption.js +29 -94
  11. package/dist/crypto/index.d.ts +10 -0
  12. package/dist/crypto/index.js +22 -0
  13. package/dist/crypto/keyExchange.d.ts +6 -20
  14. package/dist/crypto/keyExchange.js +18 -110
  15. package/dist/crypto/keyGeneration.d.ts +2 -13
  16. package/dist/crypto/keyGeneration.js +14 -88
  17. package/dist/crypto/keyStorage.d.ts +32 -5
  18. package/dist/crypto/keyStorage.js +152 -8
  19. package/dist/crypto/sessionPersistence.d.ts +7 -13
  20. package/dist/crypto/sessionPersistence.js +108 -33
  21. package/dist/crypto/types.d.ts +24 -3
  22. package/dist/crypto/types.js +2 -1
  23. package/dist/crypto/websocketE2EE.d.ts +6 -17
  24. package/dist/crypto/websocketE2EE.js +21 -38
  25. package/dist/index.js +203 -280
  26. package/dist/integration.d.ts +0 -1
  27. package/dist/integration.js +2 -4
  28. package/dist/logger.d.ts +15 -0
  29. package/dist/logger.js +209 -1
  30. package/dist/server.d.ts +30 -0
  31. package/dist/server.js +162 -0
  32. package/dist/startup.js +15 -6
  33. package/dist/terminal.d.ts +1 -0
  34. package/dist/terminal.js +94 -1
  35. package/dist/tools/claude-process.d.ts +8 -0
  36. package/dist/tools/claude-process.js +199 -26
  37. package/dist/tools/claude-sessions.d.ts +1 -0
  38. package/dist/tools/claude-sessions.js +36 -10
  39. package/dist/tools/detector.js +11 -3
  40. package/dist/tools/permission-hook.js +94 -27
  41. package/dist/tools/permission-ipc.d.ts +1 -0
  42. package/dist/tools/permission-ipc.js +61 -14
  43. package/dist/transcript-streamer.d.ts +1 -0
  44. package/dist/transcript-streamer.js +18 -4
  45. package/dist/usage-tracker.d.ts +45 -0
  46. package/dist/usage-tracker.js +243 -0
  47. package/dist/websocket.d.ts +43 -12
  48. package/dist/websocket.js +418 -214
  49. package/package.json +5 -4
  50. package/dist/__tests__/cli-commands.test.d.ts +0 -6
  51. package/dist/__tests__/cli-commands.test.d.ts.map +0 -1
  52. package/dist/__tests__/cli-commands.test.js +0 -213
  53. package/dist/__tests__/cli-commands.test.js.map +0 -1
  54. package/dist/__tests__/crypto/e2e-integration.test.d.ts +0 -17
  55. package/dist/__tests__/crypto/e2e-integration.test.d.ts.map +0 -1
  56. package/dist/__tests__/crypto/e2e-integration.test.js +0 -338
  57. package/dist/__tests__/crypto/e2e-integration.test.js.map +0 -1
  58. package/dist/__tests__/crypto/e2eeManager.test.d.ts +0 -2
  59. package/dist/__tests__/crypto/e2eeManager.test.d.ts.map +0 -1
  60. package/dist/__tests__/crypto/e2eeManager.test.js +0 -242
  61. package/dist/__tests__/crypto/e2eeManager.test.js.map +0 -1
  62. package/dist/__tests__/crypto/encryption.test.d.ts +0 -2
  63. package/dist/__tests__/crypto/encryption.test.d.ts.map +0 -1
  64. package/dist/__tests__/crypto/encryption.test.js +0 -116
  65. package/dist/__tests__/crypto/encryption.test.js.map +0 -1
  66. package/dist/__tests__/crypto/keyExchange.test.d.ts +0 -2
  67. package/dist/__tests__/crypto/keyExchange.test.d.ts.map +0 -1
  68. package/dist/__tests__/crypto/keyExchange.test.js +0 -84
  69. package/dist/__tests__/crypto/keyExchange.test.js.map +0 -1
  70. package/dist/__tests__/crypto/keyGeneration.test.d.ts +0 -2
  71. package/dist/__tests__/crypto/keyGeneration.test.d.ts.map +0 -1
  72. package/dist/__tests__/crypto/keyGeneration.test.js +0 -61
  73. package/dist/__tests__/crypto/keyGeneration.test.js.map +0 -1
  74. package/dist/__tests__/crypto/keyStorage.test.d.ts +0 -2
  75. package/dist/__tests__/crypto/keyStorage.test.d.ts.map +0 -1
  76. package/dist/__tests__/crypto/keyStorage.test.js +0 -133
  77. package/dist/__tests__/crypto/keyStorage.test.js.map +0 -1
  78. package/dist/__tests__/crypto/websocketIntegration.test.d.ts +0 -2
  79. package/dist/__tests__/crypto/websocketIntegration.test.d.ts.map +0 -1
  80. package/dist/__tests__/crypto/websocketIntegration.test.js +0 -259
  81. package/dist/__tests__/crypto/websocketIntegration.test.js.map +0 -1
  82. package/dist/__tests__/startup.test.d.ts +0 -11
  83. package/dist/__tests__/startup.test.d.ts.map +0 -1
  84. package/dist/__tests__/startup.test.js +0 -241
  85. package/dist/__tests__/startup.test.js.map +0 -1
  86. package/dist/__tests__/tools/claude-process.test.d.ts +0 -8
  87. package/dist/__tests__/tools/claude-process.test.d.ts.map +0 -1
  88. package/dist/__tests__/tools/claude-process.test.js +0 -430
  89. package/dist/__tests__/tools/claude-process.test.js.map +0 -1
  90. package/dist/__tests__/tools/permission-hook.test.d.ts +0 -17
  91. package/dist/__tests__/tools/permission-hook.test.d.ts.map +0 -1
  92. package/dist/__tests__/tools/permission-hook.test.js +0 -616
  93. package/dist/__tests__/tools/permission-hook.test.js.map +0 -1
  94. package/dist/__tests__/tools/permission-ipc.test.d.ts +0 -11
  95. package/dist/__tests__/tools/permission-ipc.test.d.ts.map +0 -1
  96. package/dist/__tests__/tools/permission-ipc.test.js +0 -612
  97. package/dist/__tests__/tools/permission-ipc.test.js.map +0 -1
  98. package/dist/__tests__/websocket.test.d.ts +0 -13
  99. package/dist/__tests__/websocket.test.d.ts.map +0 -1
  100. package/dist/__tests__/websocket.test.js +0 -204
  101. package/dist/__tests__/websocket.test.js.map +0 -1
  102. package/dist/api.d.ts +0 -44
  103. package/dist/api.d.ts.map +0 -1
  104. package/dist/api.js +0 -76
  105. package/dist/api.js.map +0 -1
  106. package/dist/approval.d.ts.map +0 -1
  107. package/dist/approval.js.map +0 -1
  108. package/dist/config.d.ts.map +0 -1
  109. package/dist/config.js.map +0 -1
  110. package/dist/crypto/e2eeManager.d.ts.map +0 -1
  111. package/dist/crypto/e2eeManager.js.map +0 -1
  112. package/dist/crypto/encryption.d.ts.map +0 -1
  113. package/dist/crypto/encryption.js.map +0 -1
  114. package/dist/crypto/keyExchange.d.ts.map +0 -1
  115. package/dist/crypto/keyExchange.js.map +0 -1
  116. package/dist/crypto/keyGeneration.d.ts.map +0 -1
  117. package/dist/crypto/keyGeneration.js.map +0 -1
  118. package/dist/crypto/keyStorage.d.ts.map +0 -1
  119. package/dist/crypto/keyStorage.js.map +0 -1
  120. package/dist/crypto/sessionPersistence.d.ts.map +0 -1
  121. package/dist/crypto/sessionPersistence.js.map +0 -1
  122. package/dist/crypto/types.d.ts.map +0 -1
  123. package/dist/crypto/types.js.map +0 -1
  124. package/dist/crypto/websocketE2EE.d.ts.map +0 -1
  125. package/dist/crypto/websocketE2EE.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/integration.d.ts.map +0 -1
  129. package/dist/integration.js.map +0 -1
  130. package/dist/logger.d.ts.map +0 -1
  131. package/dist/logger.js.map +0 -1
  132. package/dist/startup.d.ts.map +0 -1
  133. package/dist/startup.js.map +0 -1
  134. package/dist/terminal.d.ts.map +0 -1
  135. package/dist/terminal.js.map +0 -1
  136. package/dist/tools/__tests__/claude-sessions.test.d.ts +0 -2
  137. package/dist/tools/__tests__/claude-sessions.test.d.ts.map +0 -1
  138. package/dist/tools/__tests__/claude-sessions.test.js +0 -306
  139. package/dist/tools/__tests__/claude-sessions.test.js.map +0 -1
  140. package/dist/tools/claude-hooks.d.ts.map +0 -1
  141. package/dist/tools/claude-hooks.js.map +0 -1
  142. package/dist/tools/claude-process.d.ts.map +0 -1
  143. package/dist/tools/claude-process.js.map +0 -1
  144. package/dist/tools/claude-sessions.d.ts.map +0 -1
  145. package/dist/tools/claude-sessions.js.map +0 -1
  146. package/dist/tools/detector.d.ts.map +0 -1
  147. package/dist/tools/detector.js.map +0 -1
  148. package/dist/tools/index.d.ts.map +0 -1
  149. package/dist/tools/index.js.map +0 -1
  150. package/dist/tools/permission-hook.d.ts.map +0 -1
  151. package/dist/tools/permission-hook.js.map +0 -1
  152. package/dist/tools/permission-ipc.d.ts.map +0 -1
  153. package/dist/tools/permission-ipc.js.map +0 -1
  154. package/dist/transcript-streamer.d.ts.map +0 -1
  155. package/dist/transcript-streamer.js.map +0 -1
  156. package/dist/websocket.d.ts.map +0 -1
  157. package/dist/websocket.js.map +0 -1
  158. package/jest.config.js +0 -18
@@ -92,6 +92,32 @@ const DEFAULT_SAFE_TOOLS = new Set([
92
92
  // ---------------------------------------------------------------------------
93
93
  // Helpers
94
94
  // ---------------------------------------------------------------------------
95
+ function safeReadFile(filePath) {
96
+ try {
97
+ const stat = fs.lstatSync(filePath);
98
+ if (stat.isSymbolicLink()) {
99
+ console.error(`[Security] Symlink detected, refusing to read: ${filePath}`);
100
+ return null;
101
+ }
102
+ return fs.readFileSync(filePath, 'utf-8');
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ function safeWriteFile(filePath, content) {
109
+ try {
110
+ // SECURITY: Atomic write via temp file + rename to prevent TOCTOU symlink attacks
111
+ const tmpPath = filePath + '.tmp.' + process.pid;
112
+ fs.writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
113
+ fs.renameSync(tmpPath, filePath);
114
+ return true;
115
+ }
116
+ catch (err) {
117
+ console.error(`[Security] Failed to write file safely`, err.message);
118
+ return false;
119
+ }
120
+ }
95
121
  /** Write the decision JSON to stdout synchronously and exit immediately. */
96
122
  function respond(decision, reason) {
97
123
  const output = {
@@ -106,29 +132,38 @@ function respond(decision, reason) {
106
132
  fs.writeSync(1, JSON.stringify(output) + '\n');
107
133
  process.exit(decision === 'allow' ? 0 : 2);
108
134
  }
109
- /** Ensure the temp directory exists. */
135
+ /** Ensure the temp directory exists and has safe permissions. */
110
136
  function ensureTempDir() {
111
137
  if (!fs.existsSync(TEMP_DIR)) {
112
- fs.mkdirSync(TEMP_DIR, { recursive: true });
138
+ fs.mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 });
139
+ }
140
+ else {
141
+ // SECURITY: Validate existing dir isn't world/group-writable (attacker pre-creation)
142
+ // Skip on Windows — Unix permission bits aren't enforced and produce false positives
143
+ if (process.platform !== 'win32') {
144
+ const stat = fs.statSync(TEMP_DIR);
145
+ const mode = stat.mode & 0o777;
146
+ if (mode & 0o022) { // group or other writable
147
+ console.error(`[Security] Temp dir has unsafe permissions (${mode.toString(8)}), aborting`);
148
+ respond('deny', 'Permission temp directory has unsafe permissions');
149
+ }
150
+ }
113
151
  }
114
152
  }
115
- /** Cleanup request and response temp files. */
153
+ /** Cleanup request and response temp files (with symlink protection). */
116
154
  function cleanup(promptId) {
117
155
  const requestFile = path.join(TEMP_DIR, `${promptId}.request.json`);
118
156
  const responseFile = path.join(TEMP_DIR, `${promptId}.response.json`);
119
- try {
120
- if (fs.existsSync(requestFile))
121
- fs.unlinkSync(requestFile);
122
- }
123
- catch {
124
- // Best effort — ignore errors during cleanup.
125
- }
126
- try {
127
- if (fs.existsSync(responseFile))
128
- fs.unlinkSync(responseFile);
129
- }
130
- catch {
131
- // Best effort.
157
+ for (const filePath of [requestFile, responseFile]) {
158
+ try {
159
+ const stat = fs.lstatSync(filePath);
160
+ if (stat.isFile() && !stat.isSymbolicLink()) {
161
+ fs.unlinkSync(filePath);
162
+ }
163
+ }
164
+ catch {
165
+ // Best effort — file may not exist or already deleted.
166
+ }
132
167
  }
133
168
  }
134
169
  /**
@@ -137,7 +172,10 @@ function cleanup(promptId) {
137
172
  */
138
173
  function loadRules() {
139
174
  try {
140
- const raw = fs.readFileSync(RULES_FILE, 'utf-8');
175
+ const raw = safeReadFile(RULES_FILE);
176
+ if (raw === null) {
177
+ return { safeTools: new Set(DEFAULT_SAFE_TOOLS), bashPatterns: [] };
178
+ }
141
179
  const rules = JSON.parse(raw);
142
180
  if (!Array.isArray(rules)) {
143
181
  return { safeTools: new Set(DEFAULT_SAFE_TOOLS), bashPatterns: [] };
@@ -176,13 +214,38 @@ function matchesAnyPattern(command, patterns) {
176
214
  /**
177
215
  * Simple glob matching: `*` matches any sequence of characters.
178
216
  * The entire command must match (not just a prefix).
217
+ * Uses iterative matching to avoid ReDoS from regex catastrophic backtracking.
179
218
  */
180
219
  function globMatch(str, pattern) {
181
- // Escape regex special chars except *, then replace * with .*
182
- const regexStr = '^' + pattern
183
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
184
- .replace(/\*/g, '.*') + '$';
185
- return new RegExp(regexStr).test(str);
220
+ // SECURITY: Reject overly long or complex patterns to prevent DoS
221
+ if (pattern.length > 200)
222
+ return false;
223
+ // Iterative glob matcher (no regex immune to ReDoS)
224
+ let si = 0, pi = 0;
225
+ let starSi = -1, starPi = -1;
226
+ while (si < str.length) {
227
+ if (pi < pattern.length && (pattern[pi] === str[si] || pattern[pi] === '?')) {
228
+ si++;
229
+ pi++;
230
+ }
231
+ else if (pi < pattern.length && pattern[pi] === '*') {
232
+ starPi = pi;
233
+ starSi = si;
234
+ pi++;
235
+ }
236
+ else if (starPi !== -1) {
237
+ pi = starPi + 1;
238
+ starSi++;
239
+ si = starSi;
240
+ }
241
+ else {
242
+ return false;
243
+ }
244
+ }
245
+ while (pi < pattern.length && pattern[pi] === '*') {
246
+ pi++;
247
+ }
248
+ return pi === pattern.length;
186
249
  }
187
250
  /**
188
251
  * Poll for the response file until it appears or the timeout elapses.
@@ -199,10 +262,12 @@ function pollForResponse(promptId) {
199
262
  }
200
263
  try {
201
264
  if (fs.existsSync(responseFile)) {
202
- const raw = fs.readFileSync(responseFile, 'utf-8');
203
- const response = JSON.parse(raw);
204
- resolve(response);
205
- return;
265
+ const raw = safeReadFile(responseFile);
266
+ if (raw !== null) {
267
+ const response = JSON.parse(raw);
268
+ resolve(response);
269
+ return;
270
+ }
206
271
  }
207
272
  }
208
273
  catch {
@@ -270,7 +335,9 @@ async function main() {
270
335
  timestamp: Date.now(),
271
336
  };
272
337
  const requestFile = path.join(TEMP_DIR, `${promptId}.request.json`);
273
- fs.writeFileSync(requestFile, JSON.stringify(request, null, 2), 'utf-8');
338
+ if (!safeWriteFile(requestFile, JSON.stringify(request, null, 2))) {
339
+ respond('deny', 'Failed to write permission request file');
340
+ }
274
341
  console.error(`[forkoff-hook] Permission requested for ${toolName} (promptId=${promptId}), polling...`);
275
342
  const response = await pollForResponse(promptId);
276
343
  if (!response) {
@@ -46,6 +46,7 @@ declare class PermissionIpcManager extends EventEmitter {
46
46
  private readonly POLL_INTERVAL_MS;
47
47
  private readonly TEMP_DIR;
48
48
  private processedFiles;
49
+ private static readonly MAX_PROCESSED_FILES;
49
50
  /** Currently tracked terminal session ID */
50
51
  private terminalSessionId;
51
52
  /** Currently tracked session key */
@@ -57,6 +57,32 @@ const events_1 = require("events");
57
57
  const fs = __importStar(require("fs"));
58
58
  const path = __importStar(require("path"));
59
59
  const os = __importStar(require("os"));
60
+ function safeReadFile(filePath) {
61
+ try {
62
+ const stat = fs.lstatSync(filePath);
63
+ if (stat.isSymbolicLink()) {
64
+ console.error(`[Security] Symlink detected, refusing to read: ${filePath}`);
65
+ return null;
66
+ }
67
+ return fs.readFileSync(filePath, 'utf-8');
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function safeWriteFile(filePath, content) {
74
+ try {
75
+ // SECURITY: Atomic write via temp file + rename to prevent TOCTOU symlink attacks
76
+ const tmpPath = filePath + '.tmp.' + process.pid;
77
+ fs.writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
78
+ fs.renameSync(tmpPath, filePath);
79
+ return true;
80
+ }
81
+ catch (err) {
82
+ console.error(`[Security] Failed to write file safely`, err.message);
83
+ return false;
84
+ }
85
+ }
60
86
  class PermissionIpcManager extends events_1.EventEmitter {
61
87
  constructor() {
62
88
  super(...arguments);
@@ -89,7 +115,7 @@ class PermissionIpcManager extends events_1.EventEmitter {
89
115
  this.sessionKey = sessionKey;
90
116
  // Ensure temp directory exists
91
117
  try {
92
- fs.mkdirSync(this.TEMP_DIR, { recursive: true });
118
+ fs.mkdirSync(this.TEMP_DIR, { recursive: true, mode: 0o700 });
93
119
  }
94
120
  catch (err) {
95
121
  console.log(`[Permission IPC] Failed to create temp dir ${this.TEMP_DIR}: ${err.message}`);
@@ -143,12 +169,11 @@ class PermissionIpcManager extends events_1.EventEmitter {
143
169
  if (reason) {
144
170
  responseData.reason = reason;
145
171
  }
146
- try {
147
- fs.writeFileSync(responseFile, JSON.stringify(responseData), 'utf-8');
172
+ if (safeWriteFile(responseFile, JSON.stringify(responseData))) {
148
173
  console.log(`[Permission IPC] Wrote response for ${promptId}: ${decision}${reason ? ` (${reason})` : ''}`);
149
174
  }
150
- catch (err) {
151
- console.log(`[Permission IPC] Failed to write response file ${responseFile}: ${err.message}`);
175
+ else {
176
+ console.log(`[Permission IPC] Failed to write response file ${responseFile}`);
152
177
  }
153
178
  }
154
179
  /**
@@ -170,9 +195,19 @@ class PermissionIpcManager extends events_1.EventEmitter {
170
195
  for (const file of requestFiles) {
171
196
  const filePath = path.join(this.TEMP_DIR, file);
172
197
  try {
173
- const content = fs.readFileSync(filePath, 'utf-8');
198
+ const content = safeReadFile(filePath);
199
+ if (content === null) {
200
+ this.processedFiles.add(file);
201
+ console.log(`[Permission IPC] Skipped unreadable request file ${file}`);
202
+ continue;
203
+ }
174
204
  const request = JSON.parse(content);
175
- // Mark as processed to avoid duplicate handling
205
+ // Mark as processed to avoid duplicate handling (evict oldest if at cap)
206
+ if (this.processedFiles.size >= PermissionIpcManager.MAX_PROCESSED_FILES) {
207
+ const oldest = this.processedFiles.values().next().value;
208
+ if (oldest)
209
+ this.processedFiles.delete(oldest);
210
+ }
176
211
  this.processedFiles.add(file);
177
212
  const promptId = request.promptId || file.replace('.request.json', '');
178
213
  const toolName = request.toolName || 'unknown';
@@ -205,6 +240,11 @@ class PermissionIpcManager extends events_1.EventEmitter {
205
240
  catch (err) {
206
241
  // File might be partially written or malformed — skip it for now
207
242
  // but add to processed so we don't retry every poll cycle
243
+ if (this.processedFiles.size >= PermissionIpcManager.MAX_PROCESSED_FILES) {
244
+ const oldest = this.processedFiles.values().next().value;
245
+ if (oldest)
246
+ this.processedFiles.delete(oldest);
247
+ }
208
248
  this.processedFiles.add(file);
209
249
  console.log(`[Permission IPC] Failed to read request file ${file}: ${err.message}`);
210
250
  }
@@ -240,18 +280,23 @@ class PermissionIpcManager extends events_1.EventEmitter {
240
280
  */
241
281
  cleanup() {
242
282
  this.stop();
243
- // Try to clean up remaining temp files in the directory
283
+ // Try to clean up remaining request/response temp files in the directory
244
284
  try {
245
285
  const files = fs.readdirSync(this.TEMP_DIR);
246
- for (const file of files) {
286
+ const ipcFiles = files.filter((f) => f.endsWith('.request.json') || f.endsWith('.response.json'));
287
+ for (const file of ipcFiles) {
247
288
  try {
248
- fs.unlinkSync(path.join(this.TEMP_DIR, file));
289
+ const filePath = path.join(this.TEMP_DIR, file);
290
+ const stat = fs.lstatSync(filePath);
291
+ if (stat.isFile() && !stat.isSymbolicLink()) {
292
+ fs.unlinkSync(filePath);
293
+ }
249
294
  }
250
295
  catch (err) {
251
296
  // File may already be deleted by the hook script — ignore
252
297
  }
253
298
  }
254
- console.log(`[Permission IPC] Cleaned up ${files.length} temp file(s)`);
299
+ console.log(`[Permission IPC] Cleaned up ${ipcFiles.length} temp file(s)`);
255
300
  }
256
301
  catch (err) {
257
302
  // Directory might not exist — that's fine
@@ -272,12 +317,13 @@ class PermissionIpcManager extends events_1.EventEmitter {
272
317
  // Directory doesn't exist — nothing to clean
273
318
  return;
274
319
  }
320
+ const ipcFiles = files.filter((f) => f.endsWith('.request.json') || f.endsWith('.response.json'));
275
321
  let cleaned = 0;
276
- for (const file of files) {
322
+ for (const file of ipcFiles) {
277
323
  const filePath = path.join(tempDir, file);
278
324
  try {
279
- const stat = fs.statSync(filePath);
280
- if (stat.isFile()) {
325
+ const stat = fs.lstatSync(filePath);
326
+ if (stat.isFile() && !stat.isSymbolicLink()) {
281
327
  fs.unlinkSync(filePath);
282
328
  cleaned++;
283
329
  }
@@ -292,4 +338,5 @@ class PermissionIpcManager extends events_1.EventEmitter {
292
338
  }
293
339
  }
294
340
  exports.PermissionIpcManager = PermissionIpcManager;
341
+ PermissionIpcManager.MAX_PROCESSED_FILES = 500; // Cap to prevent memory DoS
295
342
  //# sourceMappingURL=permission-ipc.js.map
@@ -32,6 +32,7 @@ declare class TranscriptStreamer extends EventEmitter {
32
32
  private fileSizes;
33
33
  private lastLineNumbers;
34
34
  private processingLock;
35
+ private sessionPaths;
35
36
  /**
36
37
  * Fetch transcript history from a JSONL file
37
38
  * Supports reverse pagination: offset 0 = most recent entries
@@ -38,6 +38,7 @@ const fs = __importStar(require("fs"));
38
38
  const readline = __importStar(require("readline"));
39
39
  const events_1 = require("events");
40
40
  const chokidar = __importStar(require("chokidar"));
41
+ const MAX_WATCHERS = 50;
41
42
  class TranscriptStreamer extends events_1.EventEmitter {
42
43
  constructor() {
43
44
  super(...arguments);
@@ -45,6 +46,7 @@ class TranscriptStreamer extends events_1.EventEmitter {
45
46
  this.fileSizes = new Map();
46
47
  this.lastLineNumbers = new Map();
47
48
  this.processingLock = new Map(); // Prevent concurrent reads
49
+ this.sessionPaths = new Map(); // sessionKey -> transcriptPath
48
50
  }
49
51
  /**
50
52
  * Fetch transcript history from a JSONL file
@@ -107,9 +109,14 @@ class TranscriptStreamer extends events_1.EventEmitter {
107
109
  subscribeToUpdates(sessionKey, transcriptPath) {
108
110
  // Unsubscribe if already subscribed
109
111
  this.unsubscribeFromUpdates(sessionKey);
110
- console.log(`[Transcript] subscribeToUpdates: sessionKey=${sessionKey}, path=${transcriptPath}`);
112
+ // Guard against unbounded watcher growth
113
+ if (this.watchers.size >= MAX_WATCHERS) {
114
+ console.warn(`[Transcript] MAX_WATCHERS (${MAX_WATCHERS}) reached, refusing to subscribe: ${sessionKey}`);
115
+ return;
116
+ }
117
+ console.log(`[Transcript] subscribeToUpdates: sessionKey=${sessionKey}`);
111
118
  if (!fs.existsSync(transcriptPath)) {
112
- console.log(`[Transcript] File does not exist: ${transcriptPath}`);
119
+ console.log(`[Transcript] Transcript file does not exist`);
113
120
  return;
114
121
  }
115
122
  // Get initial file size
@@ -167,6 +174,7 @@ class TranscriptStreamer extends events_1.EventEmitter {
167
174
  }
168
175
  });
169
176
  this.watchers.set(sessionKey, watcher);
177
+ this.sessionPaths.set(sessionKey, transcriptPath);
170
178
  }
171
179
  /**
172
180
  * Unsubscribe from transcript updates
@@ -178,6 +186,11 @@ class TranscriptStreamer extends events_1.EventEmitter {
178
186
  this.watchers.delete(sessionKey);
179
187
  this.fileSizes.delete(sessionKey);
180
188
  this.processingLock.delete(sessionKey);
189
+ const transcriptPath = this.sessionPaths.get(sessionKey);
190
+ if (transcriptPath) {
191
+ this.lastLineNumbers.delete(transcriptPath);
192
+ this.sessionPaths.delete(sessionKey);
193
+ }
181
194
  }
182
195
  }
183
196
  /**
@@ -189,7 +202,7 @@ class TranscriptStreamer extends events_1.EventEmitter {
189
202
  const entries = [];
190
203
  let lineNumber = 0;
191
204
  let newLinesFound = 0;
192
- console.log(`[Transcript] readNewLines: lastLineNumber=${lastLineNumber}, path=${transcriptPath}`);
205
+ console.log(`[Transcript] readNewLines: lastLineNumber=${lastLineNumber}`);
193
206
  const fileStream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
194
207
  const rl = readline.createInterface({
195
208
  input: fileStream,
@@ -207,7 +220,7 @@ class TranscriptStreamer extends events_1.EventEmitter {
207
220
  }
208
221
  else {
209
222
  // Log first 100 chars of skipped line to understand what's being skipped
210
- console.log(`[Transcript] Line ${lineNumber} skipped (no entry), preview: ${line.substring(0, 100)}`);
223
+ console.log(`[Transcript] Line ${lineNumber} skipped (no entry)`);
211
224
  }
212
225
  }
213
226
  });
@@ -453,6 +466,7 @@ class TranscriptStreamer extends events_1.EventEmitter {
453
466
  this.fileSizes.clear();
454
467
  this.lastLineNumbers.clear();
455
468
  this.processingLock.clear();
469
+ this.sessionPaths.clear();
456
470
  }
457
471
  }
458
472
  exports.transcriptStreamer = new TranscriptStreamer();
@@ -0,0 +1,45 @@
1
+ export declare class UsageTracker {
2
+ private filePath;
3
+ private data;
4
+ private saveTimer;
5
+ private dirty;
6
+ constructor();
7
+ private load;
8
+ private scheduleSave;
9
+ private saveNow;
10
+ private ensureDay;
11
+ recordUsage(inputTokens: number, outputTokens: number): void;
12
+ recordSessionStart(): void;
13
+ /**
14
+ * Get usage stats matching the mobile UsageStats type.
15
+ */
16
+ getUsageStats(period?: 'day' | 'week' | 'month' | 'all'): {
17
+ totalInputTokens: string;
18
+ totalOutputTokens: string;
19
+ totalTokens: string;
20
+ totalSessionCount: number;
21
+ estimatedCostUsd: number;
22
+ period: string;
23
+ };
24
+ /**
25
+ * Get daily usage matching the mobile TokenUsageDaily[] type.
26
+ */
27
+ getDailyUsage(startDate?: string, endDate?: string): Array<{
28
+ date: string;
29
+ inputTokens: string;
30
+ outputTokens: string;
31
+ totalTokens: string;
32
+ sessionCount: number;
33
+ estimatedCostUsd: number | null;
34
+ }>;
35
+ /**
36
+ * Get streak info matching the mobile StreakInfo type.
37
+ */
38
+ getStreakInfo(): {
39
+ currentStreak: number;
40
+ totalActiveDays: number;
41
+ };
42
+ /** Flush pending writes immediately (call before exit). */
43
+ flush(): void;
44
+ }
45
+ //# sourceMappingURL=usage-tracker.d.ts.map