@synkro-sh/cli 1.3.41 → 1.3.43

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/dist/bootstrap.js CHANGED
@@ -793,6 +793,26 @@ esac
793
793
  # Fire-and-forget anonymized telemetry for local_only mode
794
794
  if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$VERDICT_KIND" ]; then
795
795
  (
796
+ MECH_CAT=""
797
+ BIZ_CAT=""
798
+ # For violations, run OWASP classification on user's machine
799
+ if [ "$VERDICT_KIND" = "warn" ]; then
800
+ CLASS_CACHE="$HOME/.synkro/.classification-prompt"
801
+ CLASS_PROMPT=""
802
+ if [ -f "$CLASS_CACHE" ] && find "$CLASS_CACHE" -mmin -1440 2>/dev/null | grep -q .; then
803
+ CLASS_PROMPT=$(cat "$CLASS_CACHE" 2>/dev/null)
804
+ else
805
+ CLASS_PROMPT=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/judge-prompts" \\
806
+ -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null | jq -r '.classification_prompt // empty')
807
+ [ -n "$CLASS_PROMPT" ] && echo "$CLASS_PROMPT" > "$CLASS_CACHE"
808
+ fi
809
+ if [ -n "$CLASS_PROMPT" ]; then
810
+ CLASS_INPUT=$(printf '%s\\n\\nViolation context:\\n- Tool: %s\\n- Category: %s\\n- Severity: %s\\n- Hook type: bash command judge' "$CLASS_PROMPT" "$TOOL_NAME" "$CATEGORY" "$SEVERITY")
811
+ CLASS_RESP=$(echo "$CLASS_INPUT" | claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null || echo "")
812
+ MECH_CAT=$(echo "$CLASS_RESP" | jq -r '.mechanism_category // empty' 2>/dev/null)
813
+ BIZ_CAT=$(echo "$CLASS_RESP" | jq -r '.business_category // empty' 2>/dev/null)
814
+ fi
815
+ fi
796
816
  ANON_BODY=$(jq -n \\
797
817
  --arg event_id "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
798
818
  --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
@@ -804,6 +824,9 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$VERDICT_KIND" ]; then
804
824
  --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
805
825
  --arg tool_name "$TOOL_NAME" \\
806
826
  --arg repo "\${GIT_REPO:-}" \\
827
+ --arg session_id "$SESSION_ID" \\
828
+ --arg mech_cat "$MECH_CAT" \\
829
+ --arg biz_cat "$BIZ_CAT" \\
807
830
  '{
808
831
  event_id: $event_id,
809
832
  timestamp: $timestamp,
@@ -814,7 +837,10 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$VERDICT_KIND" ]; then
814
837
  category: $category,
815
838
  model: $model,
816
839
  tool_name: $tool_name
817
- } + (if $repo != "" then {repo: $repo} else {} end)')
840
+ } + (if $repo != "" then {repo: $repo} else {} end)
841
+ + (if $session_id != "" then {session_id: $session_id} else {} end)
842
+ + (if $mech_cat != "" then {mechanism_category: $mech_cat} else {} end)
843
+ + (if $biz_cat != "" then {business_category: $biz_cat} else {} end)')
818
844
  curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
819
845
  -H "Content-Type: application/json" \\
820
846
  -H "Authorization: Bearer $JWT" \\
@@ -1279,6 +1305,25 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$DECISION" ]; then
1279
1305
  LOCAL_CATEGORY="edit_violation"
1280
1306
  fi
1281
1307
  (
1308
+ MECH_CAT=""
1309
+ BIZ_CAT=""
1310
+ if [ "$LOCAL_VERDICT" = "warn" ]; then
1311
+ CLASS_CACHE="$HOME/.synkro/.classification-prompt"
1312
+ CLASS_PROMPT=""
1313
+ if [ -f "$CLASS_CACHE" ] && find "$CLASS_CACHE" -mmin -1440 2>/dev/null | grep -q .; then
1314
+ CLASS_PROMPT=$(cat "$CLASS_CACHE" 2>/dev/null)
1315
+ else
1316
+ CLASS_PROMPT=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/judge-prompts" \\
1317
+ -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null | jq -r '.classification_prompt // empty')
1318
+ [ -n "$CLASS_PROMPT" ] && echo "$CLASS_PROMPT" > "$CLASS_CACHE"
1319
+ fi
1320
+ if [ -n "$CLASS_PROMPT" ]; then
1321
+ CLASS_INPUT=$(printf '%s\\n\\nViolation context:\\n- Tool: %s\\n- Category: %s\\n- Severity: %s\\n- Hook type: edit pre-check judge' "$CLASS_PROMPT" "$TOOL_NAME" "$LOCAL_CATEGORY" "$LOCAL_SEVERITY")
1322
+ CLASS_RESP=$(echo "$CLASS_INPUT" | claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null || echo "")
1323
+ MECH_CAT=$(echo "$CLASS_RESP" | jq -r '.mechanism_category // empty' 2>/dev/null)
1324
+ BIZ_CAT=$(echo "$CLASS_RESP" | jq -r '.business_category // empty' 2>/dev/null)
1325
+ fi
1326
+ fi
1282
1327
  ANON_BODY=$(jq -n \\
1283
1328
  --arg event_id "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
1284
1329
  --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
@@ -1289,6 +1334,9 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$DECISION" ]; then
1289
1334
  --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
1290
1335
  --arg tool_name "$TOOL_NAME" \\
1291
1336
  --arg repo "\${GIT_REPO:-}" \\
1337
+ --arg session_id "$SESSION_ID" \\
1338
+ --arg mech_cat "$MECH_CAT" \\
1339
+ --arg biz_cat "$BIZ_CAT" \\
1292
1340
  '{
1293
1341
  event_id: $event_id,
1294
1342
  timestamp: $timestamp,
@@ -1298,7 +1346,10 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$DECISION" ]; then
1298
1346
  category: $category,
1299
1347
  model: $model,
1300
1348
  tool_name: $tool_name
1301
- } + (if $repo != "" then {repo: $repo} else {} end)')
1349
+ } + (if $repo != "" then {repo: $repo} else {} end)
1350
+ + (if $session_id != "" then {session_id: $session_id} else {} end)
1351
+ + (if $mech_cat != "" then {mechanism_category: $mech_cat} else {} end)
1352
+ + (if $biz_cat != "" then {business_category: $biz_cat} else {} end)')
1302
1353
  curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
1303
1354
  -H "Content-Type: application/json" \\
1304
1355
  -H "Authorization: Bearer $JWT" \\
@@ -1597,6 +1648,25 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ]; then
1597
1648
  LOCAL_VERDICT="allow"; LOCAL_SEVERITY="audit"; LOCAL_RISK="low"
1598
1649
  fi
1599
1650
  (
1651
+ MECH_CAT=""
1652
+ BIZ_CAT=""
1653
+ if [ "$LOCAL_VERDICT" = "warn" ]; then
1654
+ CLASS_CACHE="$HOME/.synkro/.classification-prompt"
1655
+ CLASS_PROMPT=""
1656
+ if [ -f "$CLASS_CACHE" ] && find "$CLASS_CACHE" -mmin -1440 2>/dev/null | grep -q .; then
1657
+ CLASS_PROMPT=$(cat "$CLASS_CACHE" 2>/dev/null)
1658
+ else
1659
+ CLASS_PROMPT=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/judge-prompts" \\
1660
+ -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null | jq -r '.classification_prompt // empty')
1661
+ [ -n "$CLASS_PROMPT" ] && echo "$CLASS_PROMPT" > "$CLASS_CACHE"
1662
+ fi
1663
+ if [ -n "$CLASS_PROMPT" ]; then
1664
+ CLASS_INPUT=$(printf '%s\\n\\nViolation context:\\n- Tool: %s\\n- Category: %s\\n- Severity: %s\\n- Hook type: post-edit capture grader' "$CLASS_PROMPT" "$TOOL_NAME" "$CATEGORY" "$LOCAL_SEVERITY")
1665
+ CLASS_RESP=$(echo "$CLASS_INPUT" | claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null || echo "")
1666
+ MECH_CAT=$(echo "$CLASS_RESP" | jq -r '.mechanism_category // empty' 2>/dev/null)
1667
+ BIZ_CAT=$(echo "$CLASS_RESP" | jq -r '.business_category // empty' 2>/dev/null)
1668
+ fi
1669
+ fi
1600
1670
  ANON_BODY=$(jq -n \\
1601
1671
  --arg event_id "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
1602
1672
  --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
@@ -1608,11 +1678,17 @@ if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ]; then
1608
1678
  --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
1609
1679
  --arg tool_name "$TOOL_NAME" \\
1610
1680
  --arg repo "\${GIT_REPO:-}" \\
1681
+ --arg session_id "$SESSION_ID" \\
1682
+ --arg mech_cat "$MECH_CAT" \\
1683
+ --arg biz_cat "$BIZ_CAT" \\
1611
1684
  '{
1612
1685
  event_id: $event_id, timestamp: $timestamp, hook_type: $hook_type,
1613
1686
  verdict: $verdict, severity: $severity, risk_level: $risk_level,
1614
1687
  category: $category, model: $model, tool_name: $tool_name
1615
- } + (if $repo != "" then {repo: $repo} else {} end)')
1688
+ } + (if $repo != "" then {repo: $repo} else {} end)
1689
+ + (if $session_id != "" then {session_id: $session_id} else {} end)
1690
+ + (if $mech_cat != "" then {mechanism_category: $mech_cat} else {} end)
1691
+ + (if $biz_cat != "" then {business_category: $biz_cat} else {} end)')
1616
1692
  curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
1617
1693
  -H "Content-Type: application/json" \\
1618
1694
  -H "Authorization: Bearer $JWT" \\
@@ -1988,7 +2064,7 @@ Commands:
1988
2064
  status - print "running"/"stopped"
1989
2065
  """
1990
2066
 
1991
- import os, sys, json, socket, time, signal, fcntl, re, select
2067
+ import os, sys, json, socket, time, signal, fcntl, re, select, queue
1992
2068
  import subprocess, threading, urllib.request, urllib.error
1993
2069
  from pathlib import Path
1994
2070
 
@@ -2119,13 +2195,18 @@ def log(msg):
2119
2195
 
2120
2196
  STALL_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_STALL_TIMEOUT", "10"))
2121
2197
 
2122
- def _read_response(proc, timeout=45):
2198
+ def _read_response(proc, timeout=45, stop_event=None):
2199
+ """Read stream-json from proc.stdout until a 'result' message arrives.
2200
+ If stop_event is set externally, returns immediately with empty string \u2014
2201
+ used by the parallel-race grade path to abort the loser."""
2123
2202
  acc = []
2124
2203
  deadline = time.time() + timeout
2125
2204
  last_data = time.time()
2126
2205
  fd = proc.stdout.fileno()
2127
2206
  buf = ""
2128
2207
  while True:
2208
+ if stop_event is not None and stop_event.is_set():
2209
+ return ""
2129
2210
  remaining = deadline - time.time()
2130
2211
  if remaining <= 0:
2131
2212
  log("read timeout")
@@ -2133,7 +2214,7 @@ def _read_response(proc, timeout=45):
2133
2214
  if time.time() - last_data > STALL_TIMEOUT_SEC:
2134
2215
  log(f"stall timeout: no data for {STALL_TIMEOUT_SEC}s")
2135
2216
  return ""
2136
- ready, _, _ = select.select([fd], [], [], min(remaining, 2.0))
2217
+ ready, _, _ = select.select([fd], [], [], min(remaining, 1.0))
2137
2218
  if not ready:
2138
2219
  if proc.poll() is not None:
2139
2220
  log("process exited during read")
@@ -2262,39 +2343,71 @@ class WarmGrader:
2262
2343
  self._warm_proc = None
2263
2344
  self._warm_ready_at = 0.0
2264
2345
 
2265
- # Discard warm processes that have been idle too long.
2266
2346
  WARM_TTL_SEC = int(os.environ.get("SYNKRO_DAEMON_WARM_TTL", "15"))
2267
- warm = True
2268
- if not proc or proc.poll() is not None:
2269
- log("no warm process, cold fallback")
2270
- proc = self._make_proc()
2271
- warm = False
2272
- elif proc.stdin.closed:
2273
- log("warm process stdin closed, cold fallback")
2347
+ # Decide if the warm process is usable for the race.
2348
+ warm_usable = bool(proc) and proc.poll() is None and not proc.stdin.closed
2349
+ if warm_usable and ready_at and (time.time() - ready_at) > WARM_TTL_SEC:
2274
2350
  self._kill_proc(proc)
2275
- proc = self._make_proc()
2276
- warm = False
2277
- elif ready_at and (time.time() - ready_at) > WARM_TTL_SEC:
2278
- age = time.time() - ready_at
2279
- log(f"warm process stale ({age:.0f}s > {WARM_TTL_SEC}s), cold fallback")
2351
+ warm_usable = False
2352
+ if not warm_usable and proc:
2280
2353
  self._kill_proc(proc)
2281
- proc = self._make_proc()
2282
- warm = False
2354
+ proc = None
2283
2355
 
2284
2356
  wall_limit = int(os.environ.get("SYNKRO_DAEMON_WALL_TIMEOUT", "12"))
2357
+ race_timeout = min(GRADE_TIMEOUT_SEC, wall_limit)
2358
+
2359
+ # Race a warm and a fresh cold process. Whichever returns a non-empty
2360
+ # response first wins; the other is killed. If we have no warm, just
2361
+ # run cold solo (no benefit to racing two cold spawns of the same age).
2362
+ cold_proc = self._make_proc() if warm_usable else proc or self._make_proc()
2363
+ warm_proc = proc if warm_usable else None
2364
+
2365
+ result_q = queue.Queue()
2366
+ stop_event = threading.Event()
2367
+
2368
+ def grade_worker(p, label):
2369
+ try:
2370
+ _send_msg(p, prompt)
2371
+ r = _read_response(p, timeout=race_timeout, stop_event=stop_event)
2372
+ if r:
2373
+ result_q.put((label, r))
2374
+ except Exception as e:
2375
+ log(f"grade {label} error: {e}")
2376
+
2377
+ threads = []
2378
+ if warm_proc is not None:
2379
+ t = threading.Thread(target=grade_worker, args=(warm_proc, "warm"), daemon=True)
2380
+ t.start()
2381
+ threads.append(t)
2382
+ t = threading.Thread(target=grade_worker, args=(cold_proc, "cold"), daemon=True)
2383
+ t.start()
2384
+ threads.append(t)
2385
+
2285
2386
  t0 = time.time()
2387
+ winner_label = None
2388
+ resp = ""
2286
2389
  try:
2287
- _send_msg(proc, prompt)
2288
- resp = _read_response(proc, timeout=min(GRADE_TIMEOUT_SEC, wall_limit))
2289
- except Exception as e:
2290
- log(f"grade error: {e}")
2291
- resp = ""
2390
+ deadline = time.time() + race_timeout + 2
2391
+ while time.time() < deadline:
2392
+ try:
2393
+ label, r = result_q.get(timeout=0.5)
2394
+ if r:
2395
+ resp = r
2396
+ winner_label = label
2397
+ break
2398
+ except queue.Empty:
2399
+ if all(not th.is_alive() for th in threads):
2400
+ break
2292
2401
  finally:
2293
- self._kill_proc(proc)
2402
+ stop_event.set()
2403
+ if warm_proc is not None:
2404
+ self._kill_proc(warm_proc)
2405
+ self._kill_proc(cold_proc)
2294
2406
 
2295
2407
  elapsed = (time.time() - t0) * 1000
2296
2408
  self._total_grades += 1
2297
- log(f"grade #{self._total_grades} {'warm' if warm else 'cold'} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
2409
+ winner = winner_label or "none"
2410
+ log(f"grade #{self._total_grades} race={'warm+cold' if warm_proc else 'cold'} won={winner} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
2298
2411
 
2299
2412
  self._start_prewarm()
2300
2413
  return resp
@@ -3746,7 +3859,7 @@ function writeConfigEnv(opts) {
3746
3859
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3747
3860
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3748
3861
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3749
- `SYNKRO_VERSION=${shellQuoteSingle("1.3.41")}`
3862
+ `SYNKRO_VERSION=${shellQuoteSingle("1.3.43")}`
3750
3863
  ];
3751
3864
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
3752
3865
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);