litmus-cli 1.0.19 → 1.0.20

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.
@@ -146,7 +146,12 @@ async function main() {
146
146
  const raw = await readStdin()
147
147
  if (!raw.trim()) return
148
148
 
149
- const { prompt, sessionId } = extractPrompt(raw, tool)
149
+ // Detect Cursor firing Claude Code hooks — Cursor sets CURSOR_TRACE_ID
150
+ const effectiveTool = (tool === "claude" && process.env.CURSOR_TRACE_ID)
151
+ ? "cursor"
152
+ : tool
153
+
154
+ const { prompt, sessionId } = extractPrompt(raw, effectiveTool)
150
155
  if (!prompt) return
151
156
 
152
157
  const truncated = prompt.length > MAX_PROMPT_LENGTH
@@ -156,7 +161,7 @@ async function main() {
156
161
  const event = {
157
162
  ts: new Date().toISOString(),
158
163
  type: "ai_prompt",
159
- tool,
164
+ tool: effectiveTool,
160
165
  prompt: truncated,
161
166
  }
162
167
  if (sessionId) event.sessionId = sessionId
@@ -16,7 +16,9 @@
16
16
 
17
17
  const fs = require("fs")
18
18
  const path = require("path")
19
+ const crypto = require("crypto")
19
20
  const { execSync, execFile } = require("child_process")
21
+ const dns = require("dns")
20
22
  const https = require("https")
21
23
  const http = require("http")
22
24
 
@@ -122,7 +124,16 @@ function shouldIgnore(relPath) {
122
124
  return IGNORED.some((re) => re.test(relPath))
123
125
  }
124
126
 
127
+ // ── Hash chain for tamper evidence ──────────────────────────────
128
+ let prevHash = "0".repeat(64) // genesis
129
+ let nextSeq = 0
130
+
125
131
  function emit(event) {
132
+ event._seq = nextSeq++
133
+ event._prevHash = prevHash
134
+ const hash = crypto.createHash("sha256").update(JSON.stringify(event)).digest("hex")
135
+ event._hash = hash
136
+ prevHash = hash
126
137
  log.write(JSON.stringify(event) + "\n")
127
138
  if (uploadConfig && uploadBuffer.length < MAX_BUFFER_SIZE) {
128
139
  uploadBuffer.push(event)
@@ -195,6 +206,10 @@ process.on("uncaughtException", (err) => {
195
206
  try {
196
207
  const watcher = fs.watch(projectDir, { recursive: true }, (eventType, filename) => {
197
208
  if (!filename) return
209
+
210
+ // Check for AI artifact files before applying ignore filter (artifacts are dotfiles)
211
+ checkArtifact(filename)
212
+
198
213
  if (shouldIgnore(filename)) return
199
214
 
200
215
  // Check for burst edits on raw events (before debounce — needs real timing)
@@ -322,6 +337,16 @@ const AI_TOOL_PATTERNS = {
322
337
  codeium: /\bcodeium\b/,
323
338
  codex: /\bcodex\b/,
324
339
  copilot: /\bcopilot\b/,
340
+ windsurf: /\bwindsurf\b/,
341
+ ollama: /\bollama\b/,
342
+ tabnine: /\btabnine\b/,
343
+ supermaven: /\bsupermaven\b/,
344
+ cline: /\bcline\b/,
345
+ cody: /\bsourcegraph-cody\b|\bcody-agent\b/,
346
+ "amazon-q": /\bamazon-q\b/,
347
+ "roo-code": /\broo-code\b|\broo_code\b/,
348
+ pearai: /\bpearai\b/,
349
+ ghostwriter: /\bghostwriter\b/,
325
350
  }
326
351
 
327
352
  function detectEnvironment() {
@@ -353,6 +378,159 @@ function detectEnvironment() {
353
378
  detectEnvironment() // Run immediately on startup
354
379
  setInterval(detectEnvironment, 10 * 60 * 1000) // Every 10 minutes
355
380
 
381
+ // ── AI artifact file detection ──────────────────────────────────
382
+ const AI_ARTIFACT_PATTERNS = [
383
+ { tool: "cursor", paths: [".cursorrules", ".cursorignore"] },
384
+ { tool: "windsurf", paths: [".windsurfrules"] },
385
+ { tool: "aider", paths: [".aider.chat.history.md", ".aider.input.history", ".aider.tags.cache"] },
386
+ { tool: "continue", paths: [".continue"] },
387
+ { tool: "codeium", paths: [".codeium"] },
388
+ { tool: "tabnine", paths: [".tabnine"] },
389
+ { tool: "codex", paths: ["codex.md", ".codex"] },
390
+ ]
391
+
392
+ const seenArtifacts = new Set()
393
+
394
+ function checkArtifact(relPath) {
395
+ if (seenArtifacts.has(relPath)) return
396
+ for (const { tool, paths } of AI_ARTIFACT_PATTERNS) {
397
+ for (const p of paths) {
398
+ if (relPath === p || relPath.startsWith(p + "/") || relPath.startsWith(p + "\\")) {
399
+ seenArtifacts.add(relPath)
400
+ emit({
401
+ ts: new Date().toISOString(),
402
+ type: "ai_artifact_detected",
403
+ tool,
404
+ file: relPath,
405
+ })
406
+ return
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ // Startup scan for pre-existing artifacts
413
+ for (const { tool, paths } of AI_ARTIFACT_PATTERNS) {
414
+ for (const p of paths) {
415
+ try {
416
+ if (fs.existsSync(path.join(projectDir, p))) {
417
+ seenArtifacts.add(p)
418
+ emit({
419
+ ts: new Date().toISOString(),
420
+ type: "ai_artifact_detected",
421
+ tool,
422
+ file: p,
423
+ })
424
+ }
425
+ } catch { /* skip */ }
426
+ }
427
+ }
428
+
429
+ // ── Network monitoring (AI API endpoint detection) ──────────────
430
+ const AI_NETWORK_HOSTS = {
431
+ "api.openai.com": "openai",
432
+ "chat.openai.com": "chatgpt",
433
+ "chatgpt.com": "chatgpt",
434
+ "api.anthropic.com": "claude_api",
435
+ "claude.ai": "claude_web",
436
+ "generativelanguage.googleapis.com": "gemini_api",
437
+ "gemini.google.com": "gemini_web",
438
+ "copilot-proxy.githubusercontent.com": "copilot",
439
+ "githubcopilot.com": "copilot",
440
+ "api2.cursor.sh": "cursor",
441
+ "cursor.sh": "cursor",
442
+ "server.codeium.com": "codeium",
443
+ "api.perplexity.ai": "perplexity",
444
+ "perplexity.ai": "perplexity",
445
+ "api.deepseek.com": "deepseek",
446
+ "api.groq.com": "groq",
447
+ "api.mistral.ai": "mistral",
448
+ }
449
+
450
+ // IP -> tool name cache (refreshed every 30 min)
451
+ const ipToTool = new Map()
452
+ let lastDnsRefresh = 0
453
+ const DNS_REFRESH_MS = 30 * 60 * 1000
454
+
455
+ function refreshDnsCache() {
456
+ lastDnsRefresh = Date.now()
457
+ for (const [host, tool] of Object.entries(AI_NETWORK_HOSTS)) {
458
+ dns.resolve4(host, { ttl: false }, (err, addresses) => {
459
+ if (!err && addresses) {
460
+ for (const ip of addresses) {
461
+ ipToTool.set(ip, { tool, host })
462
+ }
463
+ }
464
+ })
465
+ }
466
+ }
467
+
468
+ refreshDnsCache() // Resolve on startup
469
+
470
+ let lastNetworkTools = "" // Dedup: JSON stringified tool set from previous check
471
+
472
+ function detectNetworkAI() {
473
+ if (Date.now() - lastDnsRefresh > DNS_REFRESH_MS) refreshDnsCache()
474
+ if (ipToTool.size === 0) return // DNS hasn't resolved yet
475
+
476
+ execFile("lsof", ["-i", "-n", "-P"], { timeout: 10_000, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 }, (err, stdout) => {
477
+ if (err) {
478
+ // lsof unavailable — try ss on Linux
479
+ if (err.code === "ENOENT" && process.platform === "linux") {
480
+ execFile("ss", ["-tnp"], { timeout: 5000, encoding: "utf8", maxBuffer: 1024 * 1024 }, (ssErr, ssOut) => {
481
+ if (!ssErr && ssOut) parseNetworkOutput(ssOut, true)
482
+ })
483
+ }
484
+ return
485
+ }
486
+ if (stdout) parseNetworkOutput(stdout, false)
487
+ })
488
+ }
489
+
490
+ function parseNetworkOutput(output, isSS) {
491
+ const detected = new Map() // tool -> Set<host>
492
+
493
+ for (const line of output.split("\n")) {
494
+ // lsof: match destination IP in "->IP:port" pattern
495
+ // ss: match destination IP in "IP:port" pattern after local address
496
+ const ipMatch = isSS
497
+ ? line.match(/\s(\d+\.\d+\.\d+\.\d+):(\d+)\s*$/)
498
+ : line.match(/->(\d+\.\d+\.\d+\.\d+):(\d+)/)
499
+ if (!ipMatch) continue
500
+
501
+ const ip = ipMatch[1]
502
+ const entry = ipToTool.get(ip)
503
+ if (entry) {
504
+ if (!detected.has(entry.tool)) detected.set(entry.tool, new Set())
505
+ detected.get(entry.tool).add(entry.host)
506
+ }
507
+ }
508
+
509
+ if (detected.size === 0) return
510
+
511
+ // Dedup: don't re-emit if same tools detected as last check
512
+ const toolKey = JSON.stringify([...detected.keys()].sort())
513
+ if (toolKey === lastNetworkTools) return
514
+ lastNetworkTools = toolKey
515
+
516
+ const tools = []
517
+ const hosts = []
518
+ for (const [tool, hostSet] of detected) {
519
+ tools.push(tool)
520
+ for (const h of hostSet) hosts.push(h)
521
+ }
522
+
523
+ emit({
524
+ ts: new Date().toISOString(),
525
+ type: "network_ai_detected",
526
+ tools,
527
+ hosts,
528
+ })
529
+ }
530
+
531
+ detectNetworkAI() // Run immediately
532
+ setInterval(detectNetworkAI, 5 * 60 * 1000) // Every 5 minutes
533
+
356
534
  // ── Auto-submit at deadline ──────────────────────────────────────
357
535
  // NOTE: Deadline calculation duplicated from config.ts getEffectiveDeadline()
358
536
  // (this file is CommonJS and cannot import the TS module). Keep both in sync.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litmus-cli",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "description": "CLI tool for Litmus engineering assessments",
5
5
  "license": "MIT",
6
6
  "author": "elenazhao",