sigrank 0.12.3 → 0.12.5

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/README.md CHANGED
@@ -67,7 +67,7 @@ sigrank
67
67
  ## Commands
68
68
 
69
69
  ```
70
- ⊙ SigRank CLI v0.12.1
70
+ ⊙ SigRank CLI v0.12.5
71
71
 
72
72
  Default (no args)
73
73
  sigrank unified dashboard: cascade + token pillars + board
package/cli.mjs CHANGED
@@ -28,11 +28,24 @@ import { callTool, DEFAULT_API_BASE } from './tools.mjs'
28
28
  import { classify } from './cascade.mjs'
29
29
  import { ensureIdentity, keystorePath } from './keystore.mjs'
30
30
  import { submitSignedWindow } from './submit.mjs'
31
- import { execSync } from 'child_process'
31
+ import { execFile } from 'child_process'
32
32
  import { existsSync, readFileSync, writeFileSync } from 'fs'
33
33
  import os from 'os'
34
34
  import path from 'path'
35
35
 
36
+ // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
37
+ // defense-in-depth (shell injection prevention). execFile passes args as an
38
+ // array, so no shell parsing occurs — even if platform contained special chars,
39
+ // they'd be treated as literal arguments, not shell commands.
40
+ function execFileAsync(cmd, args, timeoutMs) {
41
+ return new Promise((resolve, reject) => {
42
+ execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
43
+ if (err) reject(err)
44
+ else resolve(stdout.toString())
45
+ })
46
+ })
47
+ }
48
+
36
49
  // ── ANSI helpers (no chalk dep) ────────────────────────────────────────────
37
50
  const ESC = '\x1b['
38
51
  const c = {
@@ -407,11 +420,10 @@ async function runMe({ platform = 'claude', compare = false } = {}) {
407
420
  // identical to _freshCcusage and could be consolidated in a future pass if compare
408
421
  // switches to live data — but that's a behavior change, not a refactor.
409
422
 
410
- function ccusagePillars(platform = 'claude') {
423
+ async function ccusagePillars(platform = 'claude') {
411
424
  // ccusage <platform> daily --json → sum by window
412
425
  try {
413
- const cmd = platform === 'claude' ? 'ccusage claude daily --json' : `ccusage ${platform} daily --json`
414
- const raw = execSync(cmd, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
426
+ const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
415
427
  const data = JSON.parse(raw)
416
428
  const rows = data.daily ?? data // ccusage may return {daily:[...]} or [...]
417
429
 
@@ -494,7 +506,7 @@ function appPillars() {
494
506
  }
495
507
  }
496
508
 
497
- function tokenDashPillars() {
509
+ async function tokenDashPillars() {
498
510
  const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
499
511
  if (!existsSync(dbPath)) return null
500
512
  try {
@@ -513,9 +525,8 @@ r = db.execute(f"SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m
513
525
  out['all'] = {'input':r[0] or 0,'output':r[1] or 0,'cacheCreate':r[2] or 0,'cacheRead':r[3] or 0}
514
526
  print(json.dumps(out))
515
527
  `)
516
- const raw = execSync(`python3 "${tmpScript}" "${dbPath}"`,
517
- { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
518
- return JSON.parse(raw)
528
+ const raw = await execFileAsync('python3', [tmpScript, dbPath], 15000)
529
+ return JSON.parse(raw.trim())
519
530
  } catch {
520
531
  return null
521
532
  }
@@ -560,12 +571,12 @@ function cascadeFromPillars(p) {
560
571
  async function runCompare({ platform = 'claude' } = {}) {
561
572
  write(HIDE_CURSOR)
562
573
 
563
- // Pull all five sources in parallel
574
+ // Pull all five sources in parallel (verifiers now async via execFile)
564
575
  writeln(` ${dim('reading all 5 sources…')}`)
565
576
  const [ccPillars, tpData, tdPillars, tsPillars, apPillars] = await Promise.all([
566
- Promise.resolve(ccusagePillars(platform)),
577
+ ccusagePillars(platform).catch(() => null),
567
578
  callTool('tokenpull', { platform }).catch(() => null),
568
- Promise.resolve(platform === 'claude' ? tokenDashPillars() : null),
579
+ (platform === 'claude' ? tokenDashPillars() : Promise.resolve(null)).catch(() => null),
569
580
  Promise.resolve(tokscalePillars(platform)),
570
581
  Promise.resolve(platform === 'claude' ? appPillars() : null),
571
582
  ])
@@ -1154,8 +1165,8 @@ async function runSigRank() {
1154
1165
  if (k === 'q' || key === '\u0003') { // q or ctrl+c
1155
1166
  resolve()
1156
1167
  } else if (k === 'b') {
1157
- const { execSync: es } = await import('child_process')
1158
- try { es('open https://signalaf.com', { stdio: 'ignore' }) } catch { }
1168
+ // ASYNC FIX (2026-06-27): execFile instead of dynamic execSync import
1169
+ execFile('open', ['https://signalaf.com'], { stdio: 'ignore' }, () => {})
1159
1170
  resolve()
1160
1171
  } else if (k === 's') {
1161
1172
  const id = ensureIdentity()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/tokenpull.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { readdir, readFile, lstat } from 'node:fs/promises'
19
19
  import { existsSync } from 'node:fs'
20
- import { execSync, execFile } from 'node:child_process'
20
+ import { execFile } from 'node:child_process'
21
21
  import { join } from 'node:path'
22
22
  import { homedir } from 'node:os'
23
23
  import { ADAPTERS } from './adapters.mjs'
package/tools.mjs CHANGED
@@ -15,11 +15,24 @@ import { ALL_PLATFORMS } from './adapters.mjs'
15
15
  import { ensureIdentity, recordEnrollment } from './keystore.mjs'
16
16
  import { submitSignedWindow } from './submit.mjs'
17
17
  import { createHash } from 'node:crypto'
18
- import { execSync } from 'node:child_process'
18
+ import { execFile } from 'node:child_process'
19
19
  import { existsSync, readFileSync } from 'node:fs'
20
20
  import os from 'node:os'
21
21
  import path from 'node:path'
22
22
 
23
+ // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
24
+ // defense-in-depth (shell injection prevention + non-blocking). The platform param
25
+ // is enum-validated at the MCP schema level, but execFile also prevents shell
26
+ // interpolation attacks by passing args as an array (no shell parsing).
27
+ function execFileAsync(cmd, args, timeoutMs) {
28
+ return new Promise((resolve, reject) => {
29
+ execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
30
+ if (err) reject(err)
31
+ else resolve(stdout.toString())
32
+ })
33
+ })
34
+ }
35
+
23
36
  // ── Verifier readers (sync, on-device, token-only) ────────────────────────────
24
37
  // These mirror the implementations in cli.mjs / tui.mjs without the circular import.
25
38
  //
@@ -29,9 +42,9 @@ import path from 'node:path'
29
42
  // (bunx tokscale, scan+read tokendash) for the TUI/CLI dashboard. Different data sources =
30
43
  // different behavior; do NOT merge without understanding the trade-off.
31
44
 
32
- function _ccusagePillars(platform = 'claude') {
45
+ async function _ccusagePillars(platform = 'claude') {
33
46
  try {
34
- const raw = execSync(`ccusage ${platform} daily --json`, { timeout: 15000, stdio: ['ignore','pipe','ignore'] }).toString()
47
+ const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
35
48
  const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
36
49
  const now = Date.now()
37
50
  const result = {}
@@ -57,15 +70,14 @@ function _ccusagePillars(platform = 'claude') {
57
70
  } catch { return null }
58
71
  }
59
72
 
60
- function _tokenDashPillars() {
73
+ async function _tokenDashPillars() {
61
74
  const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
62
75
  if (!existsSync(dbPath)) return null
63
76
  try {
64
- const raw = execSync(
65
- `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
66
- { timeout: 5000, stdio: ['ignore','pipe','ignore'] }
67
- ).toString().trim()
68
- const [i,o,cw,cr] = raw.split('|').map(Number)
77
+ const raw = await execFileAsync('sqlite3', [dbPath,
78
+ 'SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages'
79
+ ], 5000)
80
+ const [i,o,cw,cr] = raw.trim().split('|').map(Number)
69
81
  return { all: { input: i||0, output: o||0, cacheCreate: cw||0, cacheRead: cr||0 } }
70
82
  } catch { return null }
71
83
  }
@@ -578,11 +590,11 @@ export async function callTool(name, args, opts = {}) {
578
590
  const platform = args?.platform || 'claude'
579
591
  const WINS = ['7d', '30d', '90d', 'all']
580
592
 
581
- // Pull all four sources in parallel (verifiers are sync, wrap in Promise.resolve)
593
+ // Pull all four sources in parallel (verifiers are now async via execFile)
582
594
  const [tpResult, ccPillars, tdPillars, tsPillars] = await Promise.all([
583
595
  pullByPlatform(platform, opts).catch(() => null),
584
- Promise.resolve(_ccusagePillars(platform)),
585
- Promise.resolve(platform === 'claude' ? _tokenDashPillars() : null),
596
+ _ccusagePillars(platform).catch(() => null),
597
+ (platform === 'claude' ? _tokenDashPillars() : Promise.resolve(null)).catch(() => null),
586
598
  Promise.resolve(_tokscalePillars(platform)),
587
599
  ])
588
600
 
package/tui.mjs CHANGED
@@ -16,7 +16,7 @@ import { callTool, DEFAULT_API_BASE } from './tools.mjs'
16
16
  import { freshVerifierPillars } from './tokenpull.mjs'
17
17
  import { isSignedIn, isCodeChar } from './connect.mjs'
18
18
  import { loadIdentity, clearIdentity } from './keystore.mjs'
19
- import { execSync } from 'child_process'
19
+ import { execFile } from 'child_process'
20
20
  import { existsSync, readFileSync } from 'fs'
21
21
  import os from 'os'
22
22
  import path from 'path'
@@ -57,6 +57,7 @@ const green = (s) => paint(c.green, s)
57
57
  const red = (s) => paint(c.red, s)
58
58
 
59
59
  const CLEAR = `${ESC}H${ESC}2J` // home + erase visible area
60
+ const CLEAR_SB = `${ESC}3J` // clear scrollback/saved lines (prevents scroll-up past TUI)
60
61
  const ENTER_ALT = `${ESC}?1049h` // enter alternate screen buffer
61
62
  const EXIT_ALT = `${ESC}?1049l` // exit alternate screen buffer (restores original)
62
63
  const HIDE = `${ESC}?25l`
@@ -1334,6 +1335,7 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1334
1335
  }
1335
1336
 
1336
1337
  write(ENTER_ALT) // switch to alternate screen — original terminal state preserved on exit
1338
+ write(CLEAR_SB) // FIXED-WINDOW (2026-06-27): clear scrollback so nothing is above the TUI
1337
1339
  write(HIDE)
1338
1340
  write(CLEAR)
1339
1341
 
@@ -1756,7 +1758,9 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1756
1758
  if (watchPlatform && watchPlatform !== 'all') watchCmd += ` --platform ${watchPlatform}`
1757
1759
  if (watchWindow && watchWindow !== 'all-windows') watchCmd += ` --window ${watchWindow}`
1758
1760
  watchCmd += ` --refresh ${watchRefresh}`
1759
- execSync(`osascript -e 'tell application "Terminal" to do script "${watchCmd}"'`, { stdio: 'ignore' })
1761
+ // ASYNC FIX (2026-06-27): execFile instead of execSync — no shell
1762
+ // interpolation. osascript args passed as array (defense-in-depth).
1763
+ execFile('osascript', ['-e', `tell application "Terminal" to do script "${watchCmd}"`], { stdio: 'ignore' }, () => {})
1760
1764
  const scope = (!watchPlatform || watchPlatform === 'all') && (!watchWindow || watchWindow === 'all-windows')
1761
1765
  ? 'all platforms × all windows' : 'focused'
1762
1766
  status = `watcher launched (${scope}, ${watchRefresh}s) in a new window`