@synkro-sh/cli 1.4.65 → 1.4.67

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
@@ -283,22 +283,32 @@ var init_ccHookConfig = __esm({
283
283
  });
284
284
 
285
285
  // cli/installer/cursorHookConfig.ts
286
- import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, mkdirSync as mkdirSync2 } from "fs";
287
- import { dirname as dirname2 } from "path";
288
- function readHooksFile(path) {
289
- if (!existsSync3(path)) return { version: 1, hooks: {} };
286
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, mkdirSync as mkdirSync2 } from "fs";
287
+ import { dirname as dirname2, resolve, normalize } from "path";
288
+ import { homedir as homedir2 } from "os";
289
+ function validateHooksPath(path) {
290
+ const resolved = resolve(normalize(path));
291
+ if (!ALLOWED_PARENT_DIRS.some((dir) => resolved.startsWith(dir + "/") || resolved === dir)) {
292
+ throw new Error(`Hooks path must be under ~/.cursor or ~/.config/cursor, got: ${resolved}`);
293
+ }
294
+ return resolved;
295
+ }
296
+ function readHooksFile(rawPath) {
297
+ const safePath = validateHooksPath(rawPath);
290
298
  try {
291
- const raw = readFileSync2(path, "utf-8");
299
+ const raw = readFileSync2(safePath, "utf-8");
292
300
  return JSON.parse(raw);
293
301
  } catch (err) {
294
- throw new Error(`Failed to parse ${path}: ${err.message}`);
302
+ if (err?.code === "ENOENT") return { version: 1, hooks: {} };
303
+ throw new Error(`Failed to parse ${safePath}: ${err.message}`);
295
304
  }
296
305
  }
297
- function writeHooksFileAtomic(path, data) {
298
- mkdirSync2(dirname2(path), { recursive: true });
299
- const tmpPath = `${path}.synkro.tmp`;
300
- writeFileSync2(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
301
- renameSync2(tmpPath, path);
306
+ function writeHooksFileAtomic(rawPath, data) {
307
+ const safePath = validateHooksPath(rawPath);
308
+ mkdirSync2(dirname2(safePath), { recursive: true });
309
+ const tmpPath = `${safePath}.synkro.tmp`;
310
+ writeFileSync2(tmpPath, JSON.stringify(data, null, 2) + "\n", { encoding: "utf-8", mode: 384 });
311
+ renameSync2(tmpPath, safePath);
302
312
  }
303
313
  function isSynkroEntry2(entry) {
304
314
  if (entry?.[SYNKRO_MARKER2]) return true;
@@ -314,10 +324,15 @@ function installCursorHooks(hooksJsonPath, config) {
314
324
  const file = readHooksFile(hooksJsonPath);
315
325
  file.version = file.version ?? 1;
316
326
  file.hooks = file.hooks ?? {};
317
- const events = ["beforeShellExecution", "preToolUse", "afterFileEdit", "postToolUse"];
318
- for (const evt of events) {
327
+ for (const evt of ALL_EVENTS) {
319
328
  removeSynkroEntries2(file.hooks, evt);
320
329
  }
330
+ file.hooks.sessionStart = file.hooks.sessionStart ?? [];
331
+ file.hooks.sessionStart.push({
332
+ command: config.sessionStartScriptPath,
333
+ timeout: 5,
334
+ [SYNKRO_MARKER2]: true
335
+ });
321
336
  file.hooks.beforeShellExecution = file.hooks.beforeShellExecution ?? [];
322
337
  file.hooks.beforeShellExecution.push({
323
338
  command: config.bashJudgeScriptPath,
@@ -346,14 +361,17 @@ function installCursorHooks(hooksJsonPath, config) {
346
361
  writeHooksFileAtomic(hooksJsonPath, file);
347
362
  }
348
363
  function uninstallCursorHooks(hooksJsonPath) {
349
- if (!existsSync3(hooksJsonPath)) return false;
350
- const file = readHooksFile(hooksJsonPath);
364
+ let file;
365
+ try {
366
+ file = readHooksFile(hooksJsonPath);
367
+ } catch {
368
+ return false;
369
+ }
351
370
  if (!file.hooks) return false;
352
- const events = ["beforeShellExecution", "preToolUse", "afterFileEdit", "postToolUse"];
353
- for (const evt of events) {
371
+ for (const evt of ALL_EVENTS) {
354
372
  removeSynkroEntries2(file.hooks, evt);
355
373
  }
356
- for (const evt of events) {
374
+ for (const evt of ALL_EVENTS) {
357
375
  if (Array.isArray(file.hooks[evt]) && file.hooks[evt].length === 0) {
358
376
  delete file.hooks[evt];
359
377
  }
@@ -365,37 +383,46 @@ function uninstallCursorHooks(hooksJsonPath) {
365
383
  return true;
366
384
  }
367
385
  function inspectCursorHooks(hooksJsonPath) {
368
- if (!existsSync3(hooksJsonPath)) {
369
- return { installed: false, beforeShellExecution: false, preToolUse: false, afterFileEdit: false, postToolUse: false };
386
+ let file;
387
+ try {
388
+ file = readHooksFile(hooksJsonPath);
389
+ } catch {
390
+ return { installed: false, sessionStart: false, beforeShellExecution: false, preToolUse: false, afterFileEdit: false, postToolUse: false };
370
391
  }
371
- const file = readHooksFile(hooksJsonPath);
372
392
  const h = file.hooks ?? {};
393
+ const sessionStart = (h.sessionStart ?? []).some((e) => isSynkroEntry2(e));
373
394
  const beforeShellExecution = (h.beforeShellExecution ?? []).some((e) => isSynkroEntry2(e));
374
395
  const preToolUse = (h.preToolUse ?? []).some((e) => isSynkroEntry2(e));
375
396
  const afterFileEdit = (h.afterFileEdit ?? []).some((e) => isSynkroEntry2(e));
376
397
  const postToolUse = (h.postToolUse ?? []).some((e) => isSynkroEntry2(e));
377
398
  return {
378
- installed: beforeShellExecution || preToolUse || afterFileEdit || postToolUse,
399
+ installed: sessionStart || beforeShellExecution || preToolUse || afterFileEdit || postToolUse,
400
+ sessionStart,
379
401
  beforeShellExecution,
380
402
  preToolUse,
381
403
  afterFileEdit,
382
404
  postToolUse
383
405
  };
384
406
  }
385
- var SYNKRO_MARKER2;
407
+ var SYNKRO_MARKER2, ALLOWED_PARENT_DIRS, ALL_EVENTS;
386
408
  var init_cursorHookConfig = __esm({
387
409
  "cli/installer/cursorHookConfig.ts"() {
388
410
  "use strict";
389
411
  SYNKRO_MARKER2 = "__synkro_managed__";
412
+ ALLOWED_PARENT_DIRS = [
413
+ resolve(homedir2(), ".cursor"),
414
+ resolve(homedir2(), ".config", "cursor")
415
+ ];
416
+ ALL_EVENTS = ["sessionStart", "beforeShellExecution", "preToolUse", "afterFileEdit", "postToolUse"];
390
417
  }
391
418
  });
392
419
 
393
420
  // cli/installer/mcpConfig.ts
394
- import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3, renameSync as renameSync3, mkdirSync as mkdirSync3 } from "fs";
395
- import { homedir as homedir2 } from "os";
421
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, renameSync as renameSync3, mkdirSync as mkdirSync3 } from "fs";
422
+ import { homedir as homedir3 } from "os";
396
423
  import { dirname as dirname3, join as join2 } from "path";
397
424
  function readClaudeJson() {
398
- if (!existsSync4(CC_CONFIG_PATH)) return {};
425
+ if (!existsSync3(CC_CONFIG_PATH)) return {};
399
426
  try {
400
427
  const raw = readFileSync3(CC_CONFIG_PATH, "utf-8");
401
428
  return JSON.parse(raw);
@@ -415,6 +442,23 @@ function installMcpConfig(opts) {
415
442
  for (const [name, entry] of Object.entries(config.mcpServers)) {
416
443
  if (entry?.[SYNKRO_MARKER3] === true) delete config.mcpServers[name];
417
444
  }
445
+ if (opts.local) {
446
+ const url2 = "http://127.0.0.1:8931/";
447
+ const tokenPath = join2(homedir3(), ".synkro", ".mcp-local-token");
448
+ let localToken = "";
449
+ try {
450
+ localToken = readFileSync3(tokenPath, "utf-8").trim();
451
+ } catch {
452
+ }
453
+ config.mcpServers[SYNKRO_SERVER_NAME] = {
454
+ type: "http",
455
+ url: url2,
456
+ ...localToken ? { headers: { Authorization: `Bearer ${localToken}` } } : {},
457
+ [SYNKRO_MARKER3]: true
458
+ };
459
+ writeClaudeJsonAtomic(config);
460
+ return { path: CC_CONFIG_PATH, url: url2 };
461
+ }
418
462
  const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/mcp/guardrails`;
419
463
  config.mcpServers[SYNKRO_SERVER_NAME] = {
420
464
  type: "http",
@@ -426,7 +470,7 @@ function installMcpConfig(opts) {
426
470
  return { path: CC_CONFIG_PATH, url };
427
471
  }
428
472
  function uninstallMcpConfig() {
429
- if (!existsSync4(CC_CONFIG_PATH)) return false;
473
+ if (!existsSync3(CC_CONFIG_PATH)) return false;
430
474
  const config = readClaudeJson();
431
475
  if (!config.mcpServers || Object.keys(config.mcpServers).length === 0) return false;
432
476
  let removed = false;
@@ -442,7 +486,7 @@ function uninstallMcpConfig() {
442
486
  return true;
443
487
  }
444
488
  function inspectMcpConfig() {
445
- if (!existsSync4(CC_CONFIG_PATH)) {
489
+ if (!existsSync3(CC_CONFIG_PATH)) {
446
490
  return { installed: false, configPath: CC_CONFIG_PATH };
447
491
  }
448
492
  const config = readClaudeJson();
@@ -458,12 +502,12 @@ var init_mcpConfig = __esm({
458
502
  "use strict";
459
503
  SYNKRO_MARKER3 = "__synkro_managed__";
460
504
  SYNKRO_SERVER_NAME = "synkro-guardrails";
461
- CC_CONFIG_PATH = join2(homedir2(), ".claude.json");
505
+ CC_CONFIG_PATH = join2(homedir3(), ".claude.json");
462
506
  }
463
507
  });
464
508
 
465
509
  // cli/installer/hookScripts.ts
466
- var SYNKRO_COMMON_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
510
+ var SYNKRO_COMMON_SCRIPT;
467
511
  var init_hookScripts = __esm({
468
512
  "cli/installer/hookScripts.ts"() {
469
513
  "use strict";
@@ -555,7 +599,32 @@ synkro_channel_up() {
555
599
  }
556
600
 
557
601
  # Fetch hook config. Sets SYNKRO_CAPTURE_DEPTH, SYNKRO_TIER, SYNKRO_RULES, SYNKRO_SILENT, SYNKRO_POLICY_NAME.
602
+ _SYNKRO_RULES_FILE="$HOME/.synkro/rules.json"
603
+ _SYNKRO_TELEMETRY_FILE="$HOME/.synkro/telemetry.jsonl"
604
+
558
605
  synkro_load_config() {
606
+ # Local-first: read from ~/.synkro/rules.json if it exists (zero latency, no network)
607
+ if [ -f "$_SYNKRO_RULES_FILE" ]; then
608
+ local rdata
609
+ rdata=$(cat "$_SYNKRO_RULES_FILE" 2>/dev/null)
610
+ if [ -n "$rdata" ]; then
611
+ SYNKRO_CAPTURE_DEPTH="local_only"
612
+ SYNKRO_TIER="standard"
613
+ SYNKRO_SILENT=$(echo "$rdata" | jq -r '.config.silent // false' 2>/dev/null)
614
+ local active_id
615
+ active_id=$(echo "$rdata" | jq -r '.config.activePolicyId // empty' 2>/dev/null)
616
+ if [ -n "$active_id" ]; then
617
+ SYNKRO_POLICY_NAME=$(echo "$rdata" | jq -r --arg id "$active_id" '.policies[]? | select(.id == $id) | .name // empty' 2>/dev/null)
618
+ SYNKRO_RULES=$(echo "$rdata" | jq -c --arg id "$active_id" '[.policies[]? | select(.id == $id) | .rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
619
+ else
620
+ SYNKRO_POLICY_NAME=$(echo "$rdata" | jq -r '.policies[0]?.name // empty' 2>/dev/null)
621
+ SYNKRO_RULES=$(echo "$rdata" | jq -c '[.policies[0]?.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
622
+ fi
623
+ return
624
+ fi
625
+ fi
626
+
627
+ # Fallback: fetch from cloud API
559
628
  local resp
560
629
  resp=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config\${1:+?$1}" -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
561
630
  if [ -z "$resp" ]; then return; fi
@@ -566,6 +635,15 @@ synkro_load_config() {
566
635
  SYNKRO_RULES=$(echo "$resp" | jq -c '[.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
567
636
  }
568
637
 
638
+ synkro_local_capture() {
639
+ local event_json="$1"
640
+ local ts
641
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
642
+ local line
643
+ line=$(echo "$event_json" | jq -c --arg ts "$ts" '. + {_ts: $ts}' 2>/dev/null)
644
+ [ -n "$line" ] && printf '%s\\n' "$line" >> "$_SYNKRO_TELEMETRY_FILE" 2>/dev/null
645
+ }
646
+
569
647
  synkro_tag() {
570
648
  if [ "$SYNKRO_SILENT" = "true" ]; then echo "[synkro:silent]"; return; fi
571
649
  local route="\${1:-$(synkro_route)}"
@@ -619,227 +697,12 @@ synkro_post_with_retry() {
619
697
  fi
620
698
  echo "$resp"
621
699
  }
622
- `;
623
- CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
624
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
625
- . "$SCRIPT_DIR/_synkro-common.sh"
626
-
627
- JWT=$(synkro_load_jwt)
628
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
629
- synkro_ensure_fresh_jwt
630
-
631
- PAYLOAD=$(cat)
632
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
633
-
634
- COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
635
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
636
-
637
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
638
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
639
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
640
-
641
- CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
642
- synkro_log "bashGuard checking: $CMD_SHORT"
643
-
644
- synkro_load_config
645
- if [ "$SYNKRO_SILENT" = "true" ]; then
646
- echo '{}'; exit 0
647
- fi
648
-
649
- BODY=$(jq -n \\
650
- --arg cmd "$COMMAND" \\
651
- --arg session_id "$SESSION_ID" \\
652
- --arg cwd "$CWD" \\
653
- --arg repo "$GIT_REPO" \\
654
- '{
655
- hook_event: "PreToolUse",
656
- tool_name: "Bash",
657
- tool_input: {command: $cmd},
658
- response_format: "cursor",
659
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
660
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
661
- repo: (if ($repo | length) > 0 then $repo else null end)
662
- }')
663
-
664
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
665
-
666
- if [ -z "$RESP" ]; then
667
- synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
668
- echo '{}'; exit 0
669
- fi
670
-
671
- # Server returns cursor-format directly in hook_response
672
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
673
- echo "$RESP" | jq -c '.hook_response'
674
- else
675
- echo '{}'
676
- fi
677
- exit 0
678
- `;
679
- CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
680
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
681
- . "$SCRIPT_DIR/_synkro-common.sh"
682
-
683
- JWT=$(synkro_load_jwt)
684
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
685
- synkro_ensure_fresh_jwt
686
-
687
- PAYLOAD=$(cat)
688
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
689
-
690
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
691
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
692
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
693
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
694
-
695
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
696
- CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
697
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
698
-
699
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
700
- synkro_log "editGuard checking: $BASENAME"
701
-
702
- synkro_load_config
703
- if [ "$SYNKRO_SILENT" = "true" ]; then
704
- echo '{}'; exit 0
705
- fi
706
-
707
- BODY=$(jq -n \\
708
- --arg file_path "$FILE_PATH" \\
709
- --arg content "$CONTENT" \\
710
- --arg session_id "$SESSION_ID" \\
711
- --arg cwd "$CWD" \\
712
- --arg repo "$GIT_REPO" \\
713
- '{
714
- hook_event: "PreToolUse",
715
- tool_name: "Edit",
716
- tool_input: {file_path: $file_path, content: $content},
717
- file_path: $file_path,
718
- content: $content,
719
- response_format: "cursor",
720
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
721
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
722
- repo: (if ($repo | length) > 0 then $repo else null end)
723
- }')
724
-
725
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
726
-
727
- if [ -z "$RESP" ]; then
728
- synkro_log "editGuard $BASENAME \u2192 error (timeout)"
729
- echo '{}'; exit 0
730
- fi
731
-
732
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
733
- echo "$RESP" | jq -c '.hook_response'
734
- else
735
- echo '{}'
736
- fi
737
- exit 0
738
- `;
739
- CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
740
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
741
- . "$SCRIPT_DIR/_synkro-common.sh"
742
-
743
- JWT=$(synkro_load_jwt)
744
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
745
-
746
- PAYLOAD=$(cat)
747
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
748
-
749
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
750
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
751
-
752
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
753
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
754
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
755
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
756
-
757
- FULL_PATH="$FILE_PATH"
758
- [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
759
- FULL_CONTENT=""
760
- [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
761
-
762
- DEPS_JSON="{}"
763
- _PKG_DIR="\${CWD:-.}"
764
- while [ "$_PKG_DIR" != "/" ]; do
765
- if [ -f "$_PKG_DIR/package.json" ]; then
766
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
767
- break
768
- fi
769
- _PKG_DIR=$(dirname "$_PKG_DIR")
770
- done
771
-
772
- synkro_log "editScan $BASENAME"
773
-
774
- (
775
- BODY=$(jq -n \\
776
- --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
777
- --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
778
- --argjson deps "$DEPS_JSON" \\
779
- '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
780
- + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
781
- + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
782
- + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
783
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
784
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
785
- -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
786
- ) &
787
- disown 2>/dev/null || true
788
-
789
- echo '{}'
790
- exit 0
791
- `;
792
- CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
793
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
794
- . "$SCRIPT_DIR/_synkro-common.sh"
795
-
796
- JWT=$(synkro_load_jwt)
797
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
798
-
799
- PAYLOAD=$(cat)
800
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
801
- case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
802
-
803
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
804
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
805
-
806
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
807
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
808
- CMD_HASH=""
809
- if [ -n "$CMD" ]; then
810
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
811
- fi
812
-
813
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
814
- if [ "$IS_ERROR" = "false" ]; then
815
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
816
- else
817
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
818
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
819
- fi
820
- fi
821
- fi
822
-
823
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
824
- (
825
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
826
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
827
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
828
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
829
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
830
- -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
831
- ) &
832
- disown 2>/dev/null || true
833
- fi
834
-
835
- echo '{}'
836
- exit 0
837
700
  `;
838
701
  }
839
702
  });
840
703
 
841
704
  // cli/installer/hookScriptsTs.ts
842
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS;
705
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_PRECHECK_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_BASH_FOLLOWUP_TS, CURSOR_SESSION_START_TS;
843
706
  var init_hookScriptsTs = __esm({
844
707
  "cli/installer/hookScriptsTs.ts"() {
845
708
  "use strict";
@@ -1019,7 +882,7 @@ export async function ensureFreshJwt(jwt: string): Promise<string> {
1019
882
 
1020
883
  export function detectRepo(cwd: string): string {
1021
884
  try {
1022
- const url = execSync('git remote get-url origin', { cwd, timeout: 3000, encoding: 'utf-8' }).trim();
885
+ const url = execSync('git remote get-url origin 2>/dev/null', { cwd, timeout: 3000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1023
886
  if (!url) return '';
1024
887
  return url
1025
888
  .replace(/^git@[^:]+:/, '')
@@ -1074,6 +937,37 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1074
937
  rules: [],
1075
938
  scanExemptions: [],
1076
939
  };
940
+
941
+ // Local-first: read from ~/.synkro/rules.json if it exists (zero latency, no network)
942
+ const localRulesPath = join(HOME, '.synkro', 'rules.json');
943
+ try {
944
+ if (existsSync(localRulesPath)) {
945
+ const raw = JSON.parse(readFileSync(localRulesPath, 'utf-8'));
946
+ const activePolicyId = raw.config?.activePolicyId || 'local-policy';
947
+ const policy = (raw.policies || []).find((p: any) => p.id === activePolicyId) || raw.policies?.[0];
948
+ if (policy) {
949
+ config.policyName = policy.name || '';
950
+ config.rules = (policy.rules || [])
951
+ .filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
952
+ .map((r: any) => ({
953
+ rule_id: r.rule_id || '',
954
+ text: r.text || '',
955
+ severity: r.severity || '',
956
+ category: r.category || '',
957
+ mode: r.mode || 'blocking',
958
+ }));
959
+ }
960
+ config.silent = raw.config?.silent === true;
961
+ if (Array.isArray(raw.scanExemptions)) {
962
+ config.scanExemptions = raw.scanExemptions
963
+ .filter((e: any) => e && typeof e.path === 'string')
964
+ .map((e: any) => ({ path: e.path, cwe_id: e.cwe_id || '' }));
965
+ }
966
+ return config;
967
+ }
968
+ } catch {}
969
+
970
+ // Fallback: fetch from cloud API
1077
971
  try {
1078
972
  const url = GATEWAY_URL + '/api/v1/hook/config' + (query ? '?' + query : '');
1079
973
  const resp = await fetch(url, {
@@ -2283,6 +2177,77 @@ import {
2283
2177
  type HookConfig, type Rule,
2284
2178
  } from './_synkro-common.ts';
2285
2179
 
2180
+ const TOP_NPM_PKGS = new Set([
2181
+ 'express','react','lodash','axios','chalk','commander','debug','dotenv','webpack',
2182
+ 'typescript','moment','uuid','cors','body-parser','mongoose','jsonwebtoken','bcrypt',
2183
+ 'nodemon','eslint','prettier','jest','mocha','chai','sinon','supertest','request',
2184
+ 'async','bluebird','underscore','ramda','rxjs','socket.io','redis','pg','mysql',
2185
+ 'sequelize','knex','prisma','next','nuxt','vue','svelte','angular','ember',
2186
+ 'react-dom','react-router','react-redux','redux','mobx','formik','yup','zod',
2187
+ 'ajv','joi','helmet','morgan','passport','cookie-parser','express-session',
2188
+ 'multer','sharp','jimp','puppeteer','playwright','cheerio','got','node-fetch',
2189
+ 'superagent','inquirer','ora','yargs','minimist','glob','rimraf','mkdirp',
2190
+ 'fs-extra','chokidar','ws','graphql','apollo-server','fastify','koa','hapi',
2191
+ 'nest','drizzle-orm','typeorm','mikro-orm','bull','bullmq','ioredis','kafkajs',
2192
+ 'amqplib','nodemailer','handlebars','ejs','pug','marked','highlight.js',
2193
+ 'dayjs','date-fns','luxon','nanoid','cuid','short-uuid','colors','picocolors',
2194
+ 'winston','pino','bunyan','semver','tar','archiver','unzipper','crypto-js',
2195
+ 'bcryptjs','argon2','jose','openai','anthropic','langchain','tensorflow',
2196
+ 'onnxruntime-node','sharp','canvas','three','d3','chart.js','echarts',
2197
+ 'tailwindcss','postcss','autoprefixer','sass','less','styled-components',
2198
+ 'emotion','framer-motion','gsap','lottie-web','swiper','i18next',
2199
+ ]);
2200
+
2201
+ const TOP_PYPI_PKGS = new Set([
2202
+ 'requests','flask','django','numpy','pandas','scipy','matplotlib','scikit-learn',
2203
+ 'tensorflow','torch','pytorch','keras','fastapi','uvicorn','gunicorn','celery',
2204
+ 'redis','sqlalchemy','alembic','pydantic','httpx','aiohttp','beautifulsoup4',
2205
+ 'scrapy','selenium','playwright','pillow','opencv-python','boto3','awscli',
2206
+ 'google-cloud-storage','azure-storage-blob','psycopg2','pymongo','motor',
2207
+ 'pytest','unittest2','mock','coverage','tox','black','flake8','mypy','ruff',
2208
+ 'isort','pylint','bandit','cryptography','paramiko','fabric','click','typer',
2209
+ 'rich','colorama','tqdm','loguru','python-dotenv','pyyaml','toml','orjson',
2210
+ 'ujson','marshmallow','attrs','dataclasses-json','jinja2','mako','arrow',
2211
+ 'pendulum','dateutil','pytz','regex','chardet','charset-normalizer',
2212
+ 'langchain','openai','anthropic','transformers','huggingface-hub','tokenizers',
2213
+ 'gradio','streamlit','dash','plotly','seaborn','bokeh','altair',
2214
+ ]);
2215
+
2216
+ function levenshtein(a: string, b: string): number {
2217
+ const m = a.length, n = b.length;
2218
+ if (Math.abs(m - n) > 2) return 3;
2219
+ const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => {
2220
+ const row = new Array(n + 1).fill(0);
2221
+ row[0] = i;
2222
+ return row;
2223
+ });
2224
+ for (let j = 1; j <= n; j++) dp[0][j] = j;
2225
+ for (let i = 1; i <= m; i++) {
2226
+ for (let j = 1; j <= n; j++) {
2227
+ dp[i][j] = a[i - 1] === b[j - 1]
2228
+ ? dp[i - 1][j - 1]
2229
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
2230
+ }
2231
+ }
2232
+ return dp[m][n];
2233
+ }
2234
+
2235
+ function checkTyposquat(pkg: string, isPip: boolean): string | null {
2236
+ const topPkgs = isPip ? TOP_PYPI_PKGS : TOP_NPM_PKGS;
2237
+ if (topPkgs.has(pkg)) return null;
2238
+ const pkgLower = pkg.toLowerCase();
2239
+ for (const known of topPkgs) {
2240
+ const dist = levenshtein(pkgLower, known);
2241
+ if (dist > 0 && dist <= 2) return known;
2242
+ }
2243
+ return null;
2244
+ }
2245
+
2246
+ interface PkgMeta {
2247
+ deprecated?: string;
2248
+ weeklyDownloads?: number;
2249
+ }
2250
+
2286
2251
  async function main() {
2287
2252
  try {
2288
2253
  const input = await readStdin();
@@ -2319,11 +2284,11 @@ async function main() {
2319
2284
  if (!jwt) { outputEmpty(); return; }
2320
2285
  jwt = await ensureFreshJwt(jwt);
2321
2286
 
2322
- // \u2500\u2500\u2500 CVE scan for package install commands \u2500\u2500\u2500
2323
- let cveScanMsg = '';
2287
+ // \u2500\u2500\u2500 Install protection: CVE + typosquat + deprecated + popularity \u2500\u2500\u2500
2288
+ let installScanMsg = '';
2324
2289
  if (toolName === 'Bash') {
2325
2290
  const pkgInstallMatch = command.match(
2326
- /(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|(?:uv\\s+)?pip3?\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+(.+)/
2291
+ /(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|(?:uv\\s+)?pip3?\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+([^|;&><]+)/
2327
2292
  );
2328
2293
  const isPip = /(?:uv\\s+)?pip3?\\s+install/.test(command);
2329
2294
  const isGo = command.match(/^go\\s+get/);
@@ -2337,6 +2302,7 @@ async function main() {
2337
2302
  let skipNext = false;
2338
2303
  for (const token of tokens) {
2339
2304
  if (skipNext) { skipNext = false; continue; }
2305
+ if (!token || !/^[@a-zA-Z]/.test(token)) continue;
2340
2306
  if (token.startsWith('-')) {
2341
2307
  if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir|filter|workspace)$/.test(token)) skipNext = true;
2342
2308
  continue;
@@ -2348,7 +2314,6 @@ async function main() {
2348
2314
  continue;
2349
2315
  }
2350
2316
  }
2351
- // npm/yarn: pkg@1.0
2352
2317
  const atIdx = token.lastIndexOf('@');
2353
2318
  if (atIdx > 0) {
2354
2319
  deps[token.slice(0, atIdx)] = token.slice(atIdx + 1);
@@ -2356,33 +2321,81 @@ async function main() {
2356
2321
  deps[token] = '*';
2357
2322
  }
2358
2323
  }
2359
- const unversioned = Object.keys(deps).filter(k => deps[k] === '*');
2360
- if (unversioned.length > 0) {
2361
- const lookups = unversioned.map(async (pkg) => {
2324
+
2325
+ if (Object.keys(deps).length > 0) {
2326
+ const warnings: string[] = [];
2327
+ const pkgMeta: Record<string, PkgMeta> = {};
2328
+
2329
+ const metaLookups = Object.keys(deps).map(async (pkg) => {
2362
2330
  try {
2363
- let ver: string | null = null;
2364
2331
  if (isPip) {
2365
2332
  const r = await fetch('https://pypi.org/pypi/' + encodeURIComponent(pkg) + '/json', { signal: AbortSignal.timeout(4000) });
2366
- if (r.ok) { const d = await r.json() as any; ver = d?.info?.version || null; }
2333
+ if (r.ok) {
2334
+ const d = await r.json() as any;
2335
+ if (deps[pkg] === '*' && d?.info?.version) deps[pkg] = d.info.version;
2336
+ const classifiers: string[] = d?.info?.classifiers || [];
2337
+ const isInactive = classifiers.some((c: string) => /Development Status :: [67]/.test(c));
2338
+ if (isInactive) pkgMeta[pkg] = { ...pkgMeta[pkg], deprecated: 'package marked as inactive/obsolete' };
2339
+ if (d?.info?.yanked) pkgMeta[pkg] = { ...pkgMeta[pkg], deprecated: d.info.yanked_reason || 'yanked from PyPI' };
2340
+ } else if (r.status === 404) {
2341
+ warnings.push('\\u26a0 ' + pkg + ': package not found on PyPI \\u2014 may not exist');
2342
+ }
2367
2343
  } else {
2368
- const r = await fetch('https://registry.npmjs.org/' + encodeURIComponent(pkg) + '/latest', { signal: AbortSignal.timeout(4000) });
2369
- if (r.ok) { const d = await r.json() as any; ver = d?.version || null; }
2344
+ const verSlug = deps[pkg] !== '*' ? deps[pkg] : 'latest';
2345
+ const [metaResp, dlResp] = await Promise.all([
2346
+ fetch('https://registry.npmjs.org/' + encodeURIComponent(pkg) + '/' + verSlug, { signal: AbortSignal.timeout(4000) }),
2347
+ fetch('https://api.npmjs.org/downloads/point/last-week/' + encodeURIComponent(pkg), { signal: AbortSignal.timeout(4000) }),
2348
+ ]);
2349
+ if (metaResp.ok) {
2350
+ const d = await metaResp.json() as any;
2351
+ if (deps[pkg] === '*' && d?.version) deps[pkg] = d.version;
2352
+ if (d?.deprecated) pkgMeta[pkg] = { ...pkgMeta[pkg], deprecated: d.deprecated };
2353
+ } else if (metaResp.status === 404) {
2354
+ warnings.push('\\u26a0 ' + pkg + ': package not found on npm \\u2014 may not exist');
2355
+ }
2356
+ if (dlResp.ok) {
2357
+ const d = await dlResp.json() as any;
2358
+ if (typeof d?.downloads === 'number') pkgMeta[pkg] = { ...pkgMeta[pkg], weeklyDownloads: d.downloads };
2359
+ }
2370
2360
  }
2371
- if (ver) deps[pkg] = ver;
2372
2361
  } catch {}
2373
2362
  });
2374
- await Promise.all(lookups);
2375
- }
2376
- const manifestFile = isPip ? 'requirements.txt'
2377
- : isGo ? 'go.mod'
2378
- : isCargo ? 'Cargo.toml'
2379
- : isGem ? 'Gemfile'
2380
- : isComposer ? 'composer.json'
2381
- : 'package.json';
2382
- const manifestContent = isPip
2383
- ? Object.entries(deps).map(([k, v]) => v === '*' ? k : k + '==' + v).join('\\n')
2384
- : JSON.stringify({ dependencies: deps });
2385
- if (Object.keys(deps).length > 0) {
2363
+ await Promise.all(metaLookups);
2364
+
2365
+ for (const pkg of Object.keys(deps)) {
2366
+ const similar = checkTyposquat(pkg, isPip);
2367
+ if (similar) {
2368
+ const dl = pkgMeta[pkg]?.weeklyDownloads;
2369
+ if (dl === undefined || dl < 1000) {
2370
+ warnings.push('\\u26a0 ' + pkg + ': possible typosquat of "' + similar + '"' + (dl !== undefined ? ' (' + dl + ' weekly downloads)' : '') + ' \\u2014 verify package name');
2371
+ }
2372
+ }
2373
+ }
2374
+
2375
+ for (const [pkg, meta] of Object.entries(pkgMeta)) {
2376
+ if (meta.deprecated) {
2377
+ warnings.push('\\u26a0 ' + pkg + ': deprecated \\u2014 ' + meta.deprecated);
2378
+ }
2379
+ }
2380
+
2381
+ if (!isPip) {
2382
+ for (const [pkg, meta] of Object.entries(pkgMeta)) {
2383
+ if (meta.weeklyDownloads !== undefined && meta.weeklyDownloads < 50 && !warnings.some(w => w.includes(pkg))) {
2384
+ warnings.push('\\u26a0 ' + pkg + ': very low adoption (' + meta.weeklyDownloads + ' weekly downloads) \\u2014 consider a more established alternative');
2385
+ }
2386
+ }
2387
+ }
2388
+
2389
+ const manifestFile = isPip ? 'requirements.txt'
2390
+ : isGo ? 'go.mod'
2391
+ : isCargo ? 'Cargo.toml'
2392
+ : isGem ? 'Gemfile'
2393
+ : isComposer ? 'composer.json'
2394
+ : 'package.json';
2395
+ const manifestContent = isPip
2396
+ ? Object.entries(deps).map(([k, v]) => v === '*' ? k : k + '==' + v).join('\\n')
2397
+ : JSON.stringify({ dependencies: deps });
2398
+
2386
2399
  try {
2387
2400
  const cveBody = { file_path: manifestFile, content: manifestContent, dependencies: deps };
2388
2401
  const cveResp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
@@ -2394,10 +2407,8 @@ async function main() {
2394
2407
 
2395
2408
  const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
2396
2409
  const scannedPkgs = Object.entries(deps).map(([k, v]) => k + '@' + v).join(', ');
2397
- if (findings.length === 0) {
2398
- cveScanMsg = '[synkro:cveScan] ' + scannedPkgs + ' \\u2192 clean, no known vulnerabilities';
2399
- }
2400
- else if (findings.length > 0) {
2410
+
2411
+ if (findings.length > 0) {
2401
2412
  const top3 = findings.slice(0, 3).map((f: any) => {
2402
2413
  const id = f.cve || f.id || '?';
2403
2414
  const pkg = f.package || '?';
@@ -2407,8 +2418,9 @@ async function main() {
2407
2418
  }).join('; ');
2408
2419
  const count = findings.length;
2409
2420
  const label = count === 1 ? 'advisory' : 'advisories';
2410
- const cveMsg = '[synkro:cveScan] ' + cmdShort + ' \\u2192 ' + count + ' ' + label;
2411
- const ctx = 'CVE: ' + top3 + '\\nDo NOT install packages with known vulnerabilities. Use a patched version or a different package.';
2421
+ const cveMsg = '[synkro:installScan] ' + cmdShort + ' \\u2192 ' + count + ' ' + label;
2422
+ const ctx = 'CVE: ' + top3 + '\\nDo NOT install packages with known vulnerabilities. Use a patched version or a different package.'
2423
+ + (warnings.length > 0 ? '\\n' + warnings.join('\\n') : '');
2412
2424
 
2413
2425
  const config = await loadConfig(jwt);
2414
2426
  for (const f of findings) {
@@ -2435,8 +2447,15 @@ async function main() {
2435
2447
  });
2436
2448
  return;
2437
2449
  }
2450
+
2451
+ const parts: string[] = ['[synkro:installScan] ' + scannedPkgs + ' \\u2192 clean, no known vulnerabilities'];
2452
+ if (warnings.length > 0) parts.push(...warnings);
2453
+ installScanMsg = parts.join('\\n');
2438
2454
  } catch (e) {
2439
- log('bashGuard CVE check failed: ' + String(e));
2455
+ log('bashGuard install scan failed: ' + String(e));
2456
+ if (warnings.length > 0) {
2457
+ installScanMsg = '[synkro:installScan] ' + warnings.join('\\n');
2458
+ }
2440
2459
  }
2441
2460
  }
2442
2461
  }
@@ -2450,7 +2469,7 @@ async function main() {
2450
2469
  const tagStr = tag(rt, config);
2451
2470
 
2452
2471
  if (config.silent) {
2453
- const msg = (cveScanMsg ? cveScanMsg + '\\n' : '') + tagStr + ' bashGuard \\u2192 skipped (silent mode)';
2472
+ const msg = (installScanMsg ? installScanMsg + '\\n' : '') + tagStr + ' bashGuard \\u2192 skipped (silent mode)';
2454
2473
  outputJson({ systemMessage: msg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
2455
2474
  return;
2456
2475
  }
@@ -2482,7 +2501,7 @@ async function main() {
2482
2501
 
2483
2502
  if (mode === 'audit') {
2484
2503
  const reason = tagStr + ' bashGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
2485
- const combined = (cveScanMsg ? cveScanMsg + '\\n' : '') + reason;
2504
+ const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2486
2505
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
2487
2506
  dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
2488
2507
  toolName, gitRepo, sessionId, config.captureDepth, {
@@ -2491,7 +2510,7 @@ async function main() {
2491
2510
  });
2492
2511
  } else {
2493
2512
  const reason = tagStr + ' bashGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
2494
- const combined = (cveScanMsg ? cveScanMsg + '\\n' : '') + reason;
2513
+ const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2495
2514
  outputJson({
2496
2515
  systemMessage: combined,
2497
2516
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: combined },
@@ -2504,7 +2523,7 @@ async function main() {
2504
2523
  }
2505
2524
  } else {
2506
2525
  const reason = tagStr + ' bashGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected');
2507
- const combined = (cveScanMsg ? cveScanMsg + '\\n' : '') + reason;
2526
+ const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2508
2527
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
2509
2528
  dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
2510
2529
  toolName, gitRepo, sessionId, config.captureDepth, {
@@ -2544,26 +2563,26 @@ async function main() {
2544
2563
 
2545
2564
  if (!resp) {
2546
2565
  log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
2547
- if (cveScanMsg) {
2548
- outputJson({ systemMessage: cveScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cveScanMsg } });
2566
+ if (installScanMsg) {
2567
+ outputJson({ systemMessage: installScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: installScanMsg } });
2549
2568
  } else { outputEmpty(); }
2550
2569
  return;
2551
2570
  }
2552
2571
 
2553
2572
  if (!resp.hook_response || typeof resp.hook_response !== 'object') {
2554
2573
  log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
2555
- if (cveScanMsg) {
2556
- outputJson({ systemMessage: cveScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cveScanMsg } });
2574
+ if (installScanMsg) {
2575
+ outputJson({ systemMessage: installScanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: installScanMsg } });
2557
2576
  } else { outputEmpty(); }
2558
2577
  return;
2559
2578
  }
2560
2579
 
2561
- if (cveScanMsg) {
2580
+ if (installScanMsg) {
2562
2581
  const existing = resp.hook_response.systemMessage || '';
2563
- resp.hook_response.systemMessage = cveScanMsg + (existing ? '\\n' + existing : '');
2582
+ resp.hook_response.systemMessage = installScanMsg + (existing ? '\\n' + existing : '');
2564
2583
  if (resp.hook_response.hookSpecificOutput) {
2565
2584
  const existingCtx = resp.hook_response.hookSpecificOutput.additionalContext || '';
2566
- resp.hook_response.hookSpecificOutput.additionalContext = cveScanMsg + (existingCtx ? '\\n' + existingCtx : '');
2585
+ resp.hook_response.hookSpecificOutput.additionalContext = installScanMsg + (existingCtx ? '\\n' + existingCtx : '');
2567
2586
  } else {
2568
2587
  resp.hook_response.hookSpecificOutput = { hookEventName: 'PreToolUse', additionalContext: resp.hook_response.systemMessage };
2569
2588
  }
@@ -3292,102 +3311,595 @@ async function main() {
3292
3311
 
3293
3312
  main();
3294
3313
  `;
3295
- }
3296
- });
3314
+ CURSOR_BASH_JUDGE_TS = `#!/usr/bin/env bun
3315
+ import {
3316
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3317
+ parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3318
+ appendLocalTelemetry, log, GATEWAY_URL,
3319
+ type HookConfig, type Rule,
3320
+ } from './_synkro-common.ts';
3297
3321
 
3298
- // cli/auth/stub.ts
3299
- import { createServer } from "http";
3300
- import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, existsSync as existsSync5, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "fs";
3301
- import { homedir as homedir3, platform } from "os";
3302
- import { join as join3, dirname as dirname4 } from "path";
3303
- import { execFile } from "child_process";
3304
- import jwt from "jsonwebtoken";
3305
- function openBrowser(url) {
3306
- const os = platform();
3307
- let bin;
3308
- let args2;
3309
- switch (os) {
3310
- case "darwin":
3311
- bin = "open";
3312
- args2 = [url];
3313
- break;
3314
- case "win32":
3315
- bin = "cmd";
3316
- args2 = ["/c", "start", "", url];
3317
- break;
3318
- default:
3319
- bin = "xdg-open";
3320
- args2 = [url];
3321
- }
3322
- execFile(bin, args2, (error) => {
3323
- if (error) {
3324
- console.error("Failed to open browser automatically.");
3325
- console.log(`Please open this URL manually: ${url}`);
3326
- }
3327
- });
3328
- }
3329
- function saveCredentials(data) {
3330
- const dir = dirname4(AUTH_FILE);
3331
- if (!existsSync5(dir)) {
3332
- mkdirSync4(dir, { recursive: true, mode: 448 });
3333
- }
3334
- writeFileSync4(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 384 });
3335
- }
3336
- function loadCredentials() {
3337
- if (!existsSync5(AUTH_FILE)) {
3338
- return null;
3339
- }
3322
+ async function main() {
3340
3323
  try {
3341
- const content = readFileSync4(AUTH_FILE, "utf8");
3342
- return JSON.parse(content);
3343
- } catch (error) {
3344
- return null;
3345
- }
3346
- }
3347
- function createCallbackServer() {
3348
- const CORS_HEADERS = {
3349
- "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL,
3350
- "Access-Control-Allow-Methods": "POST, OPTIONS",
3351
- "Access-Control-Allow-Headers": "Content-Type",
3352
- "Vary": "Origin"
3353
- };
3354
- return new Promise((resolve2, reject) => {
3355
- const server = createServer((req, res) => {
3356
- if (req.method === "OPTIONS") {
3357
- const origin = req.headers.origin;
3358
- if (origin === SYNKRO_WEB_AUTH_URL) {
3359
- res.writeHead(204, CORS_HEADERS);
3360
- } else {
3361
- res.writeHead(204, {
3362
- "Access-Control-Allow-Methods": "POST, OPTIONS",
3363
- "Access-Control-Allow-Headers": "Content-Type",
3364
- "Vary": "Origin"
3365
- });
3366
- }
3367
- res.end();
3368
- return;
3369
- }
3370
- const reqOrigin = req.headers.origin;
3371
- if (reqOrigin && reqOrigin !== SYNKRO_WEB_AUTH_URL) {
3372
- res.writeHead(403, { "Vary": "Origin" });
3373
- res.end();
3374
- return;
3375
- }
3376
- if (!req.url) {
3377
- res.writeHead(404, CORS_HEADERS);
3378
- res.end();
3379
- return;
3380
- }
3381
- const url = new URL(req.url, `http://localhost:${PORT}`);
3382
- if (url.pathname !== "/auth") {
3383
- res.writeHead(404, CORS_HEADERS);
3384
- res.end();
3324
+ const input = await readStdin();
3325
+ if (!input.trim()) { process.stdout.write('{}\\n'); return; }
3326
+
3327
+ const payload = JSON.parse(input);
3328
+ const command = payload.command || '';
3329
+ if (!command) { process.stdout.write('{}\\n'); return; }
3330
+
3331
+ const cwd = payload.cwd || '';
3332
+ const sessionId = payload.conversation_id || '';
3333
+ const repo = detectRepo(cwd || '.');
3334
+
3335
+ const cmdShort = command.slice(0, 80);
3336
+ log('bashGuard checking: ' + cmdShort);
3337
+
3338
+ let jwt = loadJwt();
3339
+ if (!jwt) { process.stdout.write('{}\\n'); return; }
3340
+ jwt = await ensureFreshJwt(jwt);
3341
+
3342
+ const config = await loadConfig(jwt);
3343
+ if (config.silent) { process.stdout.write('{}\\n'); return; }
3344
+
3345
+ const rt = await route(config);
3346
+ const tagStr = tag(rt, config);
3347
+
3348
+ if (rt === 'local') {
3349
+ // Build grading prompt with rules
3350
+ const rulesBlock = config.rules.map((r: Rule, i: number) =>
3351
+ (i + 1) + '. [' + r.rule_id + '] (' + r.severity + '/' + r.mode + ') ' + r.text
3352
+ ).join('\\n');
3353
+
3354
+ const graderPrompt = [
3355
+ 'RULES:',
3356
+ rulesBlock || '(none)',
3357
+ '',
3358
+ 'COMMAND TO EVALUATE:',
3359
+ command,
3360
+ ].join('\\n');
3361
+
3362
+ let gradeResp: string;
3363
+ try {
3364
+ gradeResp = await localGrade('bash', graderPrompt);
3365
+ } catch {
3366
+ process.stdout.write('{}\\n');
3385
3367
  return;
3386
3368
  }
3387
- if (req.method !== "POST") {
3388
- res.writeHead(405, { ...CORS_HEADERS, "Allow": "POST, OPTIONS", "Content-Type": "text/html" });
3389
- res.end(ERROR_HTML);
3390
- return;
3369
+
3370
+ const verdict = parseVerdict(gradeResp);
3371
+
3372
+ if (!verdict.ok) {
3373
+ const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3374
+ const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3375
+
3376
+ if (mode !== 'audit') {
3377
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3378
+ 'Bash', repo, sessionId, config.captureDepth, {
3379
+ command, reasoning: guardReason,
3380
+ rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
3381
+ });
3382
+ const result = {
3383
+ permission: 'deny',
3384
+ user_message: tagStr + ' bashGuard \\u2192 block: ' + guardReason,
3385
+ agent_message: 'Synkro safety judge. Reasoning: ' + (verdict.reason || guardReason),
3386
+ };
3387
+ process.stdout.write(JSON.stringify(result) + '\\n');
3388
+ return;
3389
+ }
3390
+
3391
+ // Audit mode \u2014 warn but allow
3392
+ dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3393
+ 'Bash', repo, sessionId, config.captureDepth, {
3394
+ command, reasoning: guardReason,
3395
+ rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
3396
+ });
3397
+ } else {
3398
+ dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'clean',
3399
+ 'Bash', repo, sessionId, config.captureDepth, {
3400
+ command, reasoning: verdict.reason || 'no policy violations detected',
3401
+ rulesChecked: config.rules, violatedRules: [],
3402
+ });
3403
+ }
3404
+
3405
+ process.stdout.write('{}\\n');
3406
+ return;
3407
+ }
3408
+
3409
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
3410
+ const body = {
3411
+ hook_event: 'PreToolUse',
3412
+ tool_name: 'Bash',
3413
+ tool_input: { command },
3414
+ response_format: 'cursor',
3415
+ session_id: sessionId || null,
3416
+ cwd: cwd || null,
3417
+ repo: repo || null,
3418
+ };
3419
+
3420
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 6000);
3421
+
3422
+ if (!resp) {
3423
+ log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
3424
+ process.stdout.write('{}\\n');
3425
+ return;
3426
+ }
3427
+
3428
+ if (resp.hook_response) {
3429
+ process.stdout.write(JSON.stringify(resp.hook_response) + '\\n');
3430
+ } else {
3431
+ process.stdout.write('{}\\n');
3432
+ }
3433
+ } catch {
3434
+ process.stdout.write('{}\\n');
3435
+ }
3436
+ }
3437
+
3438
+ main();
3439
+ `;
3440
+ CURSOR_EDIT_PRECHECK_TS = `#!/usr/bin/env bun
3441
+ import {
3442
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3443
+ parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3444
+ appendLocalTelemetry, log, GATEWAY_URL,
3445
+ type HookConfig, type Rule,
3446
+ } from './_synkro-common.ts';
3447
+ import { basename } from 'node:path';
3448
+
3449
+ async function main() {
3450
+ try {
3451
+ const input = await readStdin();
3452
+ if (!input.trim()) { process.stdout.write('{}\\n'); return; }
3453
+
3454
+ const payload = JSON.parse(input);
3455
+ const toolName = payload.tool_name || '';
3456
+ const toolInput = payload.tool_input || {};
3457
+ const cwd = payload.cwd || '';
3458
+ const sessionId = payload.conversation_id || '';
3459
+
3460
+ const filePath = toolInput.file_path || toolInput.path || toolInput.target_file || '';
3461
+ const content = toolInput.content || toolInput.new_string || toolInput.code_edit || '';
3462
+ if (!filePath) { process.stdout.write('{}\\n'); return; }
3463
+
3464
+ const fileShort = basename(filePath);
3465
+ log('editGuard checking: ' + fileShort);
3466
+
3467
+ const repo = detectRepo(cwd || '.');
3468
+
3469
+ let jwt = loadJwt();
3470
+ if (!jwt) { process.stdout.write('{}\\n'); return; }
3471
+ jwt = await ensureFreshJwt(jwt);
3472
+
3473
+ const config = await loadConfig(jwt);
3474
+ if (config.silent) { process.stdout.write('{}\\n'); return; }
3475
+
3476
+ const rt = await route(config);
3477
+ const tagStr = tag(rt, config);
3478
+
3479
+ if (rt === 'local') {
3480
+ const contentShort = content.slice(0, 4000);
3481
+ const rulesBlock = config.rules.map((r: Rule, i: number) =>
3482
+ (i + 1) + '. [' + r.rule_id + '] (' + r.severity + '/' + r.mode + ') ' + r.text
3483
+ ).join('\\n');
3484
+
3485
+ const graderPrompt = [
3486
+ 'RULES:',
3487
+ rulesBlock || '(none)',
3488
+ '',
3489
+ 'FILE: ' + filePath,
3490
+ '',
3491
+ 'CONTENT TO EVALUATE (first 4000 chars):',
3492
+ contentShort,
3493
+ ].join('\\n');
3494
+
3495
+ let gradeResp: string;
3496
+ try {
3497
+ gradeResp = await localGrade('edit', graderPrompt);
3498
+ } catch {
3499
+ process.stdout.write('{}\\n');
3500
+ return;
3501
+ }
3502
+
3503
+ const verdict = parseVerdict(gradeResp);
3504
+ const editContent = 'file=' + filePath + ' content=' + content.slice(0, 2000);
3505
+
3506
+ if (!verdict.ok) {
3507
+ const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3508
+ const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3509
+
3510
+ if (mode !== 'audit') {
3511
+ dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
3512
+ toolName || 'Edit', repo, sessionId, config.captureDepth, {
3513
+ command: editContent, reasoning: guardReason,
3514
+ rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
3515
+ });
3516
+ const result = {
3517
+ permission: 'deny',
3518
+ user_message: tagStr + ' editGuard ' + fileShort + ' \\u2192 block: ' + guardReason,
3519
+ agent_message: 'Synkro safety judge. Reasoning: ' + (verdict.reason || guardReason),
3520
+ };
3521
+ process.stdout.write(JSON.stringify(result) + '\\n');
3522
+ return;
3523
+ }
3524
+
3525
+ // Audit mode
3526
+ dispatchCapture(jwt, 'edit', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3527
+ toolName || 'Edit', repo, sessionId, config.captureDepth, {
3528
+ command: editContent, reasoning: guardReason,
3529
+ rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
3530
+ });
3531
+ } else {
3532
+ dispatchCapture(jwt, 'edit', 'pass', 'audit', verdict.category || 'trivial_edit',
3533
+ toolName || 'Edit', repo, sessionId, config.captureDepth, {
3534
+ command: editContent, reasoning: verdict.reason || 'no policy violations detected',
3535
+ rulesChecked: config.rules, violatedRules: [],
3536
+ });
3537
+ }
3538
+
3539
+ process.stdout.write('{}\\n');
3540
+ return;
3541
+ }
3542
+
3543
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
3544
+ const body = {
3545
+ hook_event: 'PreToolUse',
3546
+ tool_name: toolName || 'Edit',
3547
+ tool_input: { file_path: filePath, content },
3548
+ file_path: filePath,
3549
+ content,
3550
+ response_format: 'cursor',
3551
+ session_id: sessionId || null,
3552
+ cwd: cwd || null,
3553
+ repo: repo || null,
3554
+ };
3555
+
3556
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
3557
+
3558
+ if (!resp) {
3559
+ log('editGuard ' + fileShort + ' \\u2192 error (timeout)');
3560
+ process.stdout.write('{}\\n');
3561
+ return;
3562
+ }
3563
+
3564
+ if (resp.hook_response) {
3565
+ process.stdout.write(JSON.stringify(resp.hook_response) + '\\n');
3566
+ } else {
3567
+ process.stdout.write('{}\\n');
3568
+ }
3569
+ } catch {
3570
+ process.stdout.write('{}\\n');
3571
+ }
3572
+ }
3573
+
3574
+ main();
3575
+ `;
3576
+ CURSOR_EDIT_CAPTURE_TS = `#!/usr/bin/env bun
3577
+ import {
3578
+ loadJwt, ensureFreshJwt, detectRepo, readStdin,
3579
+ appendLocalTelemetry, log, GATEWAY_URL,
3580
+ } from './_synkro-common.ts';
3581
+ import { existsSync, readFileSync } from 'node:fs';
3582
+ import { basename, dirname, join } from 'node:path';
3583
+ import { homedir } from 'node:os';
3584
+
3585
+ async function main() {
3586
+ try {
3587
+ const input = await readStdin();
3588
+ if (!input.trim()) { process.stdout.write('{}\\n'); return; }
3589
+
3590
+ const payload = JSON.parse(input);
3591
+ const filePath = payload.file_path || '';
3592
+ if (!filePath) { process.stdout.write('{}\\n'); return; }
3593
+
3594
+ const cwd = payload.cwd || '';
3595
+ const sessionId = payload.conversation_id || '';
3596
+ const repo = detectRepo(cwd || '.');
3597
+
3598
+ log('editScan ' + basename(filePath));
3599
+
3600
+ let jwt = loadJwt();
3601
+ if (!jwt) { process.stdout.write('{}\\n'); return; }
3602
+ jwt = await ensureFreshJwt(jwt);
3603
+
3604
+ // Read actual file content (up to 50KB)
3605
+ let fileContent = '';
3606
+ const fullPath = filePath.startsWith('/') ? filePath : (cwd ? join(cwd, filePath) : filePath);
3607
+ try {
3608
+ if (existsSync(fullPath)) {
3609
+ const buf = readFileSync(fullPath);
3610
+ fileContent = buf.slice(0, 50000).toString('utf-8');
3611
+ }
3612
+ } catch {}
3613
+
3614
+ // Walk up to find package.json dependencies
3615
+ let dependencies: Record<string, string> = {};
3616
+ let pkgDir = cwd || dirname(fullPath);
3617
+ while (pkgDir !== '/' && pkgDir !== '.') {
3618
+ const pkgPath = join(pkgDir, 'package.json');
3619
+ if (existsSync(pkgPath)) {
3620
+ try {
3621
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
3622
+ dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
3623
+ } catch {}
3624
+ break;
3625
+ }
3626
+ const parent = dirname(pkgDir);
3627
+ if (parent === pkgDir) break;
3628
+ pkgDir = parent;
3629
+ }
3630
+
3631
+ const captureBody: Record<string, any> = {
3632
+ capture_type: 'edit_scan',
3633
+ tool_input: { file_path: filePath, content: fileContent },
3634
+ edit_verdict: { ok: true },
3635
+ dependencies,
3636
+ };
3637
+ if (sessionId) captureBody.session_id = sessionId;
3638
+ if (cwd) captureBody.cwd = cwd;
3639
+ if (repo) captureBody.repo = repo;
3640
+
3641
+ // Check if local_only
3642
+ const rulesPath = join(homedir(), '.synkro', 'rules.json');
3643
+ if (existsSync(rulesPath)) {
3644
+ appendLocalTelemetry(captureBody);
3645
+ } else {
3646
+ // Fire-and-forget to cloud
3647
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
3648
+ method: 'POST',
3649
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3650
+ body: JSON.stringify(captureBody),
3651
+ signal: AbortSignal.timeout(10000),
3652
+ }).catch(() => {});
3653
+ appendLocalTelemetry(captureBody);
3654
+ }
3655
+
3656
+ process.stdout.write('{}\\n');
3657
+ } catch {
3658
+ process.stdout.write('{}\\n');
3659
+ }
3660
+ }
3661
+
3662
+ main();
3663
+ `;
3664
+ CURSOR_BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
3665
+ import {
3666
+ loadJwt, readStdin, appendLocalTelemetry, log, GATEWAY_URL,
3667
+ } from './_synkro-common.ts';
3668
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
3669
+ import { join, dirname } from 'node:path';
3670
+ import { createHash } from 'node:crypto';
3671
+ import { homedir } from 'node:os';
3672
+
3673
+ const CONSENT_FILE = join(homedir(), '.synkro', '.local-consent');
3674
+
3675
+ function hashCmd(cmd: string): string {
3676
+ return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
3677
+ }
3678
+
3679
+ function consentGrant(sid: string, hash: string): void {
3680
+ try {
3681
+ const dir = dirname(CONSENT_FILE);
3682
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3683
+ appendFileSync(CONSENT_FILE, sid + '\\t' + hash + '\\tactive\\n', 'utf-8');
3684
+ } catch {}
3685
+ }
3686
+
3687
+ function consentHasActive(sid: string, hash: string): boolean {
3688
+ try {
3689
+ if (!existsSync(CONSENT_FILE)) return false;
3690
+ const content = readFileSync(CONSENT_FILE, 'utf-8');
3691
+ return content.includes(sid + '\\t' + hash + '\\tactive');
3692
+ } catch {
3693
+ return false;
3694
+ }
3695
+ }
3696
+
3697
+ function consentConsume(sid: string, hash: string): void {
3698
+ try {
3699
+ if (!existsSync(CONSENT_FILE)) return;
3700
+ const content = readFileSync(CONSENT_FILE, 'utf-8');
3701
+ const target = sid + '\\t' + hash + '\\tactive';
3702
+ const replacement = sid + '\\t' + hash + '\\tconsumed';
3703
+ const updated = content.split('\\n').map((l: string) => l === target ? replacement : l).join('\\n');
3704
+ writeFileSync(CONSENT_FILE, updated, 'utf-8');
3705
+ } catch {}
3706
+ }
3707
+
3708
+ async function main() {
3709
+ try {
3710
+ const input = await readStdin();
3711
+ if (!input.trim()) { process.stdout.write('{}\\n'); return; }
3712
+
3713
+ const payload = JSON.parse(input);
3714
+ const toolName = payload.tool_name || '';
3715
+
3716
+ // Only process shell/bash tool types
3717
+ const shellTools = ['Shell', 'Bash', 'terminal', 'run_terminal_cmd', 'execute_command'];
3718
+ if (!shellTools.includes(toolName)) { process.stdout.write('{}\\n'); return; }
3719
+
3720
+ const sessionId = payload.conversation_id || '';
3721
+ const toolUseId = payload.tool_use_id || '';
3722
+ const isError = payload.tool_result?.is_error === true;
3723
+ const command = payload.tool_input?.command || '';
3724
+ const cmdHash = command ? hashCmd(command) : '';
3725
+
3726
+ // Consent tracking
3727
+ if (cmdHash && sessionId) {
3728
+ if (!isError) {
3729
+ consentConsume(sessionId, cmdHash);
3730
+ } else {
3731
+ if (!consentHasActive(sessionId, cmdHash)) {
3732
+ consentGrant(sessionId, cmdHash);
3733
+ }
3734
+ }
3735
+ }
3736
+
3737
+ // Build capture body
3738
+ const captureBody: Record<string, any> = {
3739
+ capture_type: 'bash_followup',
3740
+ session_id: sessionId || null,
3741
+ tool_use_id: toolUseId || null,
3742
+ is_error: isError,
3743
+ command_hash: cmdHash,
3744
+ };
3745
+
3746
+ // Check if local_only
3747
+ const rulesPath = join(homedir(), '.synkro', 'rules.json');
3748
+ if (existsSync(rulesPath)) {
3749
+ appendLocalTelemetry(captureBody);
3750
+ } else {
3751
+ const jwt = loadJwt();
3752
+ if (jwt && sessionId && toolUseId) {
3753
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
3754
+ method: 'POST',
3755
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3756
+ body: JSON.stringify(captureBody),
3757
+ signal: AbortSignal.timeout(3000),
3758
+ }).catch(() => {});
3759
+ }
3760
+ appendLocalTelemetry(captureBody);
3761
+ }
3762
+
3763
+ process.stdout.write('{}\\n');
3764
+ } catch {
3765
+ process.stdout.write('{}\\n');
3766
+ }
3767
+ }
3768
+
3769
+ main();
3770
+ `;
3771
+ CURSOR_SESSION_START_TS = `#!/usr/bin/env bun
3772
+ import {
3773
+ loadJwt, loadConfig, readStdin,
3774
+ type HookConfig,
3775
+ } from './_synkro-common.ts';
3776
+
3777
+ async function main() {
3778
+ try {
3779
+ const input = await readStdin();
3780
+
3781
+ let jwt = loadJwt();
3782
+ const config: HookConfig = jwt ? await loadConfig(jwt) : {
3783
+ captureDepth: 'local_only', tier: 'standard', silent: false,
3784
+ policyName: '', rules: [], scanExemptions: [],
3785
+ };
3786
+
3787
+ const policyName = config.policyName || 'default';
3788
+ const ruleCount = config.rules.length;
3789
+ const mode = config.silent ? 'silent' : 'active';
3790
+
3791
+ const context = [
3792
+ 'This session is monitored by Synkro (' + mode + ' mode, policy: "' + policyName + '", ' + ruleCount + ' rules).',
3793
+ 'Synkro enforces security and compliance rules on tool calls (shell commands, file edits).',
3794
+ 'If a tool call is blocked, Synkro will explain which rule was violated and why.',
3795
+ 'Do not suggest workarounds to bypass Synkro hooks \u2014 fix the underlying issue instead.',
3796
+ ].join(' ');
3797
+
3798
+ const result = { additional_context: context };
3799
+ process.stdout.write(JSON.stringify(result) + '\\n');
3800
+ } catch {
3801
+ process.stdout.write('{}\\n');
3802
+ }
3803
+ }
3804
+
3805
+ main();
3806
+ `;
3807
+ }
3808
+ });
3809
+
3810
+ // cli/auth/stub.ts
3811
+ import { createServer } from "http";
3812
+ import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "fs";
3813
+ import { homedir as homedir4, platform } from "os";
3814
+ import { join as join3, dirname as dirname4 } from "path";
3815
+ import { execFile } from "child_process";
3816
+ import jwt from "jsonwebtoken";
3817
+ function openBrowser(url) {
3818
+ const os = platform();
3819
+ let bin;
3820
+ let args2;
3821
+ switch (os) {
3822
+ case "darwin":
3823
+ bin = "open";
3824
+ args2 = [url];
3825
+ break;
3826
+ case "win32":
3827
+ bin = "cmd";
3828
+ args2 = ["/c", "start", "", url];
3829
+ break;
3830
+ default:
3831
+ bin = "xdg-open";
3832
+ args2 = [url];
3833
+ }
3834
+ execFile(bin, args2, (error) => {
3835
+ if (error) {
3836
+ console.error("Failed to open browser automatically.");
3837
+ console.log(`Please open this URL manually: ${url}`);
3838
+ }
3839
+ });
3840
+ }
3841
+ function saveCredentials(data) {
3842
+ const dir = dirname4(AUTH_FILE);
3843
+ if (!existsSync4(dir)) {
3844
+ mkdirSync4(dir, { recursive: true, mode: 448 });
3845
+ }
3846
+ writeFileSync4(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 384 });
3847
+ }
3848
+ function loadCredentials() {
3849
+ if (!existsSync4(AUTH_FILE)) {
3850
+ return null;
3851
+ }
3852
+ try {
3853
+ const content = readFileSync4(AUTH_FILE, "utf8");
3854
+ return JSON.parse(content);
3855
+ } catch (error) {
3856
+ return null;
3857
+ }
3858
+ }
3859
+ function createCallbackServer() {
3860
+ const CORS_HEADERS = {
3861
+ "Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL,
3862
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
3863
+ "Access-Control-Allow-Headers": "Content-Type",
3864
+ "Vary": "Origin"
3865
+ };
3866
+ return new Promise((resolve3, reject) => {
3867
+ const server = createServer((req, res) => {
3868
+ if (req.method === "OPTIONS") {
3869
+ const origin = req.headers.origin;
3870
+ if (origin === SYNKRO_WEB_AUTH_URL) {
3871
+ res.writeHead(204, CORS_HEADERS);
3872
+ } else {
3873
+ res.writeHead(204, {
3874
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
3875
+ "Access-Control-Allow-Headers": "Content-Type",
3876
+ "Vary": "Origin"
3877
+ });
3878
+ }
3879
+ res.end();
3880
+ return;
3881
+ }
3882
+ const reqOrigin = req.headers.origin;
3883
+ if (reqOrigin && reqOrigin !== SYNKRO_WEB_AUTH_URL) {
3884
+ res.writeHead(403, { "Vary": "Origin" });
3885
+ res.end();
3886
+ return;
3887
+ }
3888
+ if (!req.url) {
3889
+ res.writeHead(404, CORS_HEADERS);
3890
+ res.end();
3891
+ return;
3892
+ }
3893
+ const url = new URL(req.url, `http://localhost:${PORT}`);
3894
+ if (url.pathname !== "/auth") {
3895
+ res.writeHead(404, CORS_HEADERS);
3896
+ res.end();
3897
+ return;
3898
+ }
3899
+ if (req.method !== "POST") {
3900
+ res.writeHead(405, { ...CORS_HEADERS, "Allow": "POST, OPTIONS", "Content-Type": "text/html" });
3901
+ res.end(ERROR_HTML);
3902
+ return;
3391
3903
  }
3392
3904
  const MAX_BODY = 16 * 1024;
3393
3905
  const chunks = [];
@@ -3440,7 +3952,7 @@ function createCallbackServer() {
3440
3952
  res.end(JSON.stringify({ ok: true }));
3441
3953
  setTimeout(() => {
3442
3954
  server.close();
3443
- resolve2(authData);
3955
+ resolve3(authData);
3444
3956
  }, 200);
3445
3957
  });
3446
3958
  req.on("error", (e) => {
@@ -3582,7 +4094,7 @@ async function ensureValidToken() {
3582
4094
  return true;
3583
4095
  }
3584
4096
  function clearCredentials() {
3585
- if (existsSync5(AUTH_FILE)) {
4097
+ if (existsSync4(AUTH_FILE)) {
3586
4098
  unlinkSync2(AUTH_FILE);
3587
4099
  }
3588
4100
  }
@@ -3593,7 +4105,7 @@ var init_stub = __esm({
3593
4105
  PORT = 8100;
3594
4106
  RAW_WEB_AUTH_URL = process.env.SYNKRO_WEB_AUTH_URL;
3595
4107
  SYNKRO_WEB_AUTH_URL = RAW_WEB_AUTH_URL && /^https?:\/\//.test(RAW_WEB_AUTH_URL) ? RAW_WEB_AUTH_URL : "https://app.synkro.sh";
3596
- AUTH_FILE = process.env.SYNKRO_AUTH_FILE || join3(homedir3(), ".synkro", "credentials.json");
4108
+ AUTH_FILE = process.env.SYNKRO_AUTH_FILE || join3(homedir4(), ".synkro", "credentials.json");
3597
4109
  RAW_API_URL = process.env.SYNKRO_CRUD_URL || process.env.SYNKRO_API_URL;
3598
4110
  SYNKRO_API_URL = RAW_API_URL && /^https?:\/\//.test(RAW_API_URL) ? RAW_API_URL : "https://api.synkro.sh";
3599
4111
  ERROR_HTML = `
@@ -3756,7 +4268,7 @@ jobs:
3756
4268
  });
3757
4269
 
3758
4270
  // cli/installer/githubSetup.ts
3759
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
4271
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
3760
4272
  import { execSync as execSync2 } from "child_process";
3761
4273
  import { join as join4 } from "path";
3762
4274
  function ghSecretSet(token, owner, repo, name, value) {
@@ -3813,7 +4325,7 @@ function writeWorkflowFile(repoRootPath) {
3813
4325
  function findGitRoot(startCwd) {
3814
4326
  let cur = startCwd;
3815
4327
  while (cur && cur !== "/") {
3816
- if (existsSync6(join4(cur, ".git"))) return cur;
4328
+ if (existsSync5(join4(cur, ".git"))) return cur;
3817
4329
  const parent = join4(cur, "..");
3818
4330
  if (parent === cur) break;
3819
4331
  cur = parent;
@@ -3851,10 +4363,10 @@ function detectGitRepo() {
3851
4363
  }
3852
4364
  }
3853
4365
  function ask(rl, question) {
3854
- return new Promise((resolve2) => rl.question(question, resolve2));
4366
+ return new Promise((resolve3) => rl.question(question, resolve3));
3855
4367
  }
3856
4368
  function waitForGithubToken() {
3857
- return new Promise((resolve2, reject) => {
4369
+ return new Promise((resolve3, reject) => {
3858
4370
  const server = createServer2((req, res) => {
3859
4371
  if (req.method === "OPTIONS") {
3860
4372
  res.writeHead(204, {
@@ -3891,7 +4403,7 @@ function waitForGithubToken() {
3891
4403
  });
3892
4404
  res.end(JSON.stringify({ ok: true }));
3893
4405
  setTimeout(() => server.close(), 200);
3894
- resolve2(parsed.github_token);
4406
+ resolve3(parsed.github_token);
3895
4407
  } catch {
3896
4408
  res.writeHead(400, { "Content-Type": "application/json" });
3897
4409
  res.end(JSON.stringify({ error: "invalid json" }));
@@ -4056,12 +4568,12 @@ __export(setupGithub_exports, {
4056
4568
  import { createInterface as createInterface2 } from "readline/promises";
4057
4569
  import { stdin as input, stdout as output } from "process";
4058
4570
  import { execSync as execSync4, spawn as nodeSpawn } from "child_process";
4059
- import { existsSync as existsSync7, readFileSync as readFileSync5, unlinkSync as unlinkSync3 } from "fs";
4060
- import { homedir as homedir4, platform as platform2 } from "os";
4571
+ import { existsSync as existsSync6, readFileSync as readFileSync5, unlinkSync as unlinkSync3 } from "fs";
4572
+ import { homedir as homedir5, platform as platform2 } from "os";
4061
4573
  import { join as join5 } from "path";
4062
4574
  import { execFile as execFile2 } from "child_process";
4063
4575
  function readConfig() {
4064
- if (!existsSync7(CONFIG_PATH)) return {};
4576
+ if (!existsSync6(CONFIG_PATH)) return {};
4065
4577
  const out = {};
4066
4578
  for (const line of readFileSync5(CONFIG_PATH, "utf-8").split("\n")) {
4067
4579
  const t = line.trim();
@@ -4076,7 +4588,7 @@ async function prompt(rl, q, opts = {}) {
4076
4588
  process.stdout.write(q);
4077
4589
  const wasRaw = process.stdin.isRaw;
4078
4590
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
4079
- return await new Promise((resolve2) => {
4591
+ return await new Promise((resolve3) => {
4080
4592
  let chunk = "";
4081
4593
  const onData = (data) => {
4082
4594
  const s = data.toString("utf-8");
@@ -4084,7 +4596,7 @@ async function prompt(rl, q, opts = {}) {
4084
4596
  process.stdin.removeListener("data", onData);
4085
4597
  if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false);
4086
4598
  process.stdout.write("\n");
4087
- resolve2(chunk);
4599
+ resolve3(chunk);
4088
4600
  return;
4089
4601
  }
4090
4602
  if (s === "") process.exit(130);
@@ -4125,7 +4637,7 @@ function sleep(ms) {
4125
4637
  }
4126
4638
  function captureClaudeSetupToken() {
4127
4639
  const tmpFile = join5(SYNKRO_DIR, `token-capture-${Date.now()}.raw`);
4128
- return new Promise((resolve2, reject) => {
4640
+ return new Promise((resolve3, reject) => {
4129
4641
  const proc = nodeSpawn("script", ["-q", tmpFile, "claude", "setup-token"], {
4130
4642
  stdio: "inherit"
4131
4643
  });
@@ -4155,7 +4667,7 @@ function captureClaudeSetupToken() {
4155
4667
  reject(new Error(`Could not find token in claude setup-token output (file=${raw.length}b, yellow=${yellow.length}b)`));
4156
4668
  return;
4157
4669
  }
4158
- resolve2(token[0]);
4670
+ resolve3(token[0]);
4159
4671
  });
4160
4672
  });
4161
4673
  }
@@ -4405,7 +4917,7 @@ var init_setupGithub = __esm({
4405
4917
  "use strict";
4406
4918
  init_githubSetup();
4407
4919
  init_stub();
4408
- SYNKRO_DIR = join5(homedir4(), ".synkro");
4920
+ SYNKRO_DIR = join5(homedir5(), ".synkro");
4409
4921
  CONFIG_PATH = join5(SYNKRO_DIR, "config.env");
4410
4922
  }
4411
4923
  });
@@ -4434,11 +4946,11 @@ var init_promptFetcher = __esm({
4434
4946
  });
4435
4947
 
4436
4948
  // cli/local-cc/settings.ts
4437
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
4438
- import { homedir as homedir5 } from "os";
4949
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
4950
+ import { homedir as homedir6 } from "os";
4439
4951
  import { join as join6 } from "path";
4440
4952
  function isLocalCCEnabled() {
4441
- if (!existsSync8(CONFIG_PATH2)) return false;
4953
+ if (!existsSync7(CONFIG_PATH2)) return false;
4442
4954
  try {
4443
4955
  const content = readFileSync6(CONFIG_PATH2, "utf-8");
4444
4956
  const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
@@ -4451,7 +4963,7 @@ var CONFIG_PATH2;
4451
4963
  var init_settings = __esm({
4452
4964
  "cli/local-cc/settings.ts"() {
4453
4965
  "use strict";
4454
- CONFIG_PATH2 = join6(homedir5(), ".synkro", "config.env");
4966
+ CONFIG_PATH2 = join6(homedir6(), ".synkro", "config.env");
4455
4967
  }
4456
4968
  });
4457
4969
 
@@ -4605,9 +5117,9 @@ await mcp.connect(new StdioServerTransport());
4605
5117
  });
4606
5118
 
4607
5119
  // cli/local-cc/install.ts
4608
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readFileSync as readFileSync7, chmodSync, copyFileSync, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
5120
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readFileSync as readFileSync7, chmodSync, copyFileSync, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
4609
5121
  import { join as join7 } from "path";
4610
- import { homedir as homedir6 } from "os";
5122
+ import { homedir as homedir7 } from "os";
4611
5123
  import { spawnSync } from "child_process";
4612
5124
  function writePluginFiles() {
4613
5125
  mkdirSync6(SESSION_DIR, { recursive: true });
@@ -4656,7 +5168,7 @@ function runBunInstall() {
4656
5168
  }
4657
5169
  }
4658
5170
  function safelyMutateClaudeJson(mutator) {
4659
- if (!existsSync9(CLAUDE_JSON_PATH)) {
5171
+ if (!existsSync8(CLAUDE_JSON_PATH)) {
4660
5172
  return;
4661
5173
  }
4662
5174
  const originalText = readFileSync7(CLAUDE_JSON_PATH, "utf-8");
@@ -4809,17 +5321,17 @@ var init_install = __esm({
4809
5321
  "cli/local-cc/install.ts"() {
4810
5322
  "use strict";
4811
5323
  init_channelSource();
4812
- CLAUDE_JSON_BACKUP_PATH = join7(homedir6(), ".claude.json.synkro-bak");
4813
- SESSION_DIR = join7(homedir6(), ".synkro", "cc_sessions");
5324
+ CLAUDE_JSON_BACKUP_PATH = join7(homedir7(), ".claude.json.synkro-bak");
5325
+ SESSION_DIR = join7(homedir7(), ".synkro", "cc_sessions");
4814
5326
  PLUGIN_PATH = join7(SESSION_DIR, "synkro-channel.ts");
4815
5327
  PLUGIN_PKG_PATH = join7(SESSION_DIR, "package.json");
4816
5328
  PLUGIN_SETTINGS_DIR = join7(SESSION_DIR, ".claude");
4817
5329
  PLUGIN_SETTINGS_PATH = join7(PLUGIN_SETTINGS_DIR, "settings.json");
4818
5330
  PROJECT_MCP_PATH = join7(SESSION_DIR, ".mcp.json");
4819
- CLAUDE_JSON_PATH = join7(homedir6(), ".claude.json");
5331
+ CLAUDE_JSON_PATH = join7(homedir7(), ".claude.json");
4820
5332
  RUN_SCRIPT_PATH = join7(SESSION_DIR, "run-claude.sh");
4821
5333
  TMUX_SESSION_NAME = "synkro-local-cc";
4822
- SESSION_DIR_2 = join7(homedir6(), ".synkro", "cc_sessions_2");
5334
+ SESSION_DIR_2 = join7(homedir7(), ".synkro", "cc_sessions_2");
4823
5335
  PLUGIN_PATH_2 = join7(SESSION_DIR_2, "synkro-channel.ts");
4824
5336
  PLUGIN_PKG_PATH_2 = join7(SESSION_DIR_2, "package.json");
4825
5337
  PLUGIN_SETTINGS_DIR_2 = join7(SESSION_DIR_2, ".claude");
@@ -4978,7 +5490,7 @@ log "tmux session ended."
4978
5490
 
4979
5491
  // cli/local-cc/pueue.ts
4980
5492
  import { execFileSync, spawnSync as spawnSync2, spawn } from "child_process";
4981
- import { homedir as homedir7 } from "os";
5493
+ import { homedir as homedir8 } from "os";
4982
5494
  import { join as join8 } from "path";
4983
5495
  import { connect } from "net";
4984
5496
  function pueueAvailable() {
@@ -4994,568 +5506,1377 @@ function statusJson() {
4994
5506
  throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
4995
5507
  }
4996
5508
  try {
4997
- return JSON.parse(r.stdout);
4998
- } catch (err) {
4999
- throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
5509
+ return JSON.parse(r.stdout);
5510
+ } catch (err) {
5511
+ throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
5512
+ }
5513
+ }
5514
+ function statusName(s) {
5515
+ if (typeof s === "string") return s;
5516
+ if (s && typeof s === "object") {
5517
+ if ("Running" in s) return "Running";
5518
+ if ("Done" in s) {
5519
+ const result = s.Done?.result;
5520
+ if (typeof result === "string") return `Done (${result})`;
5521
+ if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
5522
+ return "Done";
5523
+ }
5524
+ return Object.keys(s)[0] ?? "unknown";
5525
+ }
5526
+ return "unknown";
5527
+ }
5528
+ function findTask(channel = CHANNEL_PRIMARY) {
5529
+ const data = statusJson();
5530
+ for (const [id, t] of Object.entries(data.tasks)) {
5531
+ if (t.label === channel.taskLabel) {
5532
+ return {
5533
+ id: Number(id),
5534
+ label: t.label,
5535
+ status: statusName(t.status),
5536
+ command: t.command,
5537
+ cwd: t.path
5538
+ };
5539
+ }
5540
+ }
5541
+ return null;
5542
+ }
5543
+ function startTask(opts = {}) {
5544
+ const ch = opts.channel ?? CHANNEL_PRIMARY;
5545
+ const cwd = opts.cwd ?? ch.sessionDir;
5546
+ let existing = findTask(ch);
5547
+ while (existing) {
5548
+ if (existing.status === "Running" || existing.status === "Queued") {
5549
+ spawnSync2("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
5550
+ spawnSync2("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
5551
+ for (let i = 0; i < 10; i++) {
5552
+ const check = findTask(ch);
5553
+ if (!check || check.id !== existing.id || check.status !== "Running" && check.status !== "Queued") break;
5554
+ spawnSync2("sleep", ["0.5"], { encoding: "utf-8" });
5555
+ }
5556
+ }
5557
+ spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
5558
+ existing = findTask(ch);
5559
+ }
5560
+ const runScript = join8(cwd, "run-claude.sh");
5561
+ const args2 = [
5562
+ "add",
5563
+ "--label",
5564
+ ch.taskLabel,
5565
+ "--working-directory",
5566
+ cwd,
5567
+ "--",
5568
+ "bash",
5569
+ runScript
5570
+ ];
5571
+ const r = spawnSync2("pueue", args2, { encoding: "utf-8" });
5572
+ if (r.status !== 0) {
5573
+ throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
5574
+ }
5575
+ const created = findTask(ch);
5576
+ if (!created) {
5577
+ throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
5578
+ }
5579
+ return created;
5580
+ }
5581
+ function stopTask(channel = CHANNEL_PRIMARY) {
5582
+ spawnSync2("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
5583
+ let t = findTask(channel);
5584
+ while (t) {
5585
+ if (t.status === "Running" || t.status === "Queued") {
5586
+ spawnSync2("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
5587
+ for (let i = 0; i < 10; i++) {
5588
+ const check = findTask(channel);
5589
+ if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
5590
+ spawnSync2("sleep", ["0.5"], { encoding: "utf-8" });
5591
+ }
5592
+ }
5593
+ spawnSync2("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
5594
+ t = findTask(channel);
5595
+ }
5596
+ }
5597
+ function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
5598
+ const t = findTask(channel);
5599
+ if (!t) return `(no ${channel.taskLabel} task)`;
5600
+ const r = spawnSync2("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
5601
+ return r.stdout || r.stderr || "(no output)";
5602
+ }
5603
+ function ensureRunning(opts = {}) {
5604
+ const ch = opts.channel ?? CHANNEL_PRIMARY;
5605
+ const t = findTask(ch);
5606
+ if (t && t.status === "Running") return t;
5607
+ return startTask(opts);
5608
+ }
5609
+ function probePort(host, port, timeoutMs = 500) {
5610
+ return new Promise((resolve3) => {
5611
+ const sock = connect(port, host);
5612
+ const done = (ok) => {
5613
+ try {
5614
+ sock.destroy();
5615
+ } catch {
5616
+ }
5617
+ resolve3(ok);
5618
+ };
5619
+ sock.once("connect", () => done(true));
5620
+ sock.once("error", () => done(false));
5621
+ sock.setTimeout(timeoutMs, () => done(false));
5622
+ });
5623
+ }
5624
+ function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
5625
+ spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
5626
+ spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
5627
+ }
5628
+ async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
5629
+ const deadline = Date.now() + timeoutMs;
5630
+ while (Date.now() < deadline) {
5631
+ if (await probePort(host, port)) return true;
5632
+ tmuxDismissPrompts(tmuxSession);
5633
+ await new Promise((r) => setTimeout(r, 1e3));
5634
+ }
5635
+ return probePort(host, port);
5636
+ }
5637
+ function brewInstall(pkg) {
5638
+ const brew = spawnSync2("brew", ["--version"], { encoding: "utf-8" });
5639
+ if (brew.status !== 0) return false;
5640
+ console.log(` Installing ${pkg} via brew...`);
5641
+ const r = spawnSync2("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
5642
+ return r.status === 0;
5643
+ }
5644
+ function assertPueueInstalled() {
5645
+ let r = spawnSync2("pueue", ["--version"], { encoding: "utf-8" });
5646
+ if (r.status !== 0) {
5647
+ if (process.platform === "darwin" && brewInstall("pueue")) {
5648
+ r = spawnSync2("pueue", ["--version"], { encoding: "utf-8" });
5649
+ if (r.status !== 0) throw new PueueError("pueue install succeeded but binary not found on PATH.");
5650
+ } else {
5651
+ throw new PueueError("pueue not found. Install it: brew install pueue (macOS) or https://github.com/Nukesor/pueue");
5652
+ }
5653
+ }
5654
+ const status = spawnSync2("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
5655
+ if (status.status !== 0) {
5656
+ console.log(" Starting pueued daemon...");
5657
+ const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
5658
+ child.unref();
5659
+ spawnSync2("sleep", ["1"]);
5660
+ const retry = spawnSync2("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
5661
+ if (retry.status !== 0) {
5662
+ throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
5663
+ }
5664
+ }
5665
+ spawnSync2("pueue", ["parallel", "2"], { encoding: "utf-8" });
5666
+ }
5667
+ function assertClaudeInstalled() {
5668
+ const r = spawnSync2("claude", ["--version"], { encoding: "utf-8" });
5669
+ if (r.status !== 0) {
5670
+ throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
5671
+ }
5672
+ }
5673
+ function assertTmuxInstalled() {
5674
+ let r = spawnSync2("tmux", ["-V"], { encoding: "utf-8" });
5675
+ if (r.status !== 0) {
5676
+ if (process.platform === "darwin" && brewInstall("tmux")) {
5677
+ r = spawnSync2("tmux", ["-V"], { encoding: "utf-8" });
5678
+ if (r.status !== 0) throw new PueueError("tmux install succeeded but binary not found on PATH.");
5679
+ } else {
5680
+ throw new PueueError("tmux not found. Install it: brew install tmux (macOS) or apt install tmux (Linux)");
5681
+ }
5682
+ }
5683
+ }
5684
+ var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY;
5685
+ var init_pueue = __esm({
5686
+ "cli/local-cc/pueue.ts"() {
5687
+ "use strict";
5688
+ TASK_LABEL = "synkro-local-cc";
5689
+ TMUX_SESSION = "synkro-local-cc";
5690
+ SESSION_DIR2 = join8(homedir8(), ".synkro", "cc_sessions");
5691
+ TASK_LABEL_2 = "synkro-local-cc-2";
5692
+ TMUX_SESSION_2 = "synkro-local-cc-2";
5693
+ SESSION_DIR_22 = join8(homedir8(), ".synkro", "cc_sessions_2");
5694
+ PueueError = class extends Error {
5695
+ constructor(message, cause) {
5696
+ super(message);
5697
+ this.cause = cause;
5698
+ this.name = "PueueError";
5699
+ }
5700
+ cause;
5701
+ };
5702
+ CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
5703
+ CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
5704
+ }
5705
+ });
5706
+
5707
+ // cli/local-cc/prompts.ts
5708
+ import { readFileSync as readFileSync8 } from "fs";
5709
+ import { homedir as homedir9 } from "os";
5710
+ import { join as join9 } from "path";
5711
+ async function fetchPrimers() {
5712
+ let jwt2 = "";
5713
+ let gatewayUrl = "";
5714
+ try {
5715
+ const creds = JSON.parse(readFileSync8(CREDS_PATH, "utf-8"));
5716
+ jwt2 = creds.access_token || "";
5717
+ gatewayUrl = creds.gateway_url || "https://api.synkro.sh";
5718
+ } catch {
5719
+ throw new Error("No credentials found. Run `synkro install` first.");
5720
+ }
5721
+ if (!jwt2) throw new Error("No access token. Run `synkro install` first.");
5722
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/judge-prompts`, {
5723
+ headers: { Authorization: `Bearer ${jwt2}` },
5724
+ signal: AbortSignal.timeout(5e3)
5725
+ });
5726
+ if (!resp.ok) throw new Error(`Failed to fetch prompts: ${resp.status}`);
5727
+ return resp.json();
5728
+ }
5729
+ async function getPrimer(role) {
5730
+ const prompts = await fetchPrimers();
5731
+ const primer = role === "grade-edit" ? prompts.grader_primer_edit : role === "grade-plan" ? prompts.grader_primer_plan : role === "grade-cwe" ? prompts.grader_primer_cwe : prompts.grader_primer_bash;
5732
+ if (!primer) {
5733
+ throw new Error(`No primer for role "${role}" returned from API.`);
5734
+ }
5735
+ return primer;
5736
+ }
5737
+ async function buildChannelContent(role, payload) {
5738
+ const primer = await getPrimer(role);
5739
+ return `${primer}
5740
+
5741
+ ${CHANNEL_REPLY_INSTRUCTIONS}
5742
+
5743
+ ---
5744
+ PAYLOAD (the input to evaluate):
5745
+
5746
+ ${payload}`;
5747
+ }
5748
+ var CREDS_PATH, CHANNEL_REPLY_INSTRUCTIONS;
5749
+ var init_prompts = __esm({
5750
+ "cli/local-cc/prompts.ts"() {
5751
+ "use strict";
5752
+ CREDS_PATH = join9(homedir9(), ".synkro", "credentials.json");
5753
+ CHANNEL_REPLY_INSTRUCTIONS = `
5754
+ DELIVERY METHOD \u2014 MANDATORY, OVERRIDES ALL OTHER OUTPUT RULES:
5755
+ You are running inside a Synkro MCP channel. Do NOT output your verdict as text.
5756
+ Instead, after generating your verdict, call the \`reply\` tool EXACTLY ONCE with:
5757
+ - req_id: the req_id from this channel event's meta
5758
+ - result: your complete verdict block as a string (the <synkro-verdict>\u2026</synkro-verdict> XML)
5759
+ Any text output is silently discarded. Only the reply tool call is captured.`;
5760
+ }
5761
+ });
5762
+
5763
+ // cli/local-cc/turnLog.ts
5764
+ import { appendFileSync, existsSync as existsSync9, mkdirSync as mkdirSync7, openSync as openSync2, readFileSync as readFileSync9, readSync, closeSync as closeSync2, statSync, watchFile, unwatchFile } from "fs";
5765
+ import { dirname as dirname5, join as join10 } from "path";
5766
+ import { homedir as homedir10 } from "os";
5767
+ function truncate(s, max = PREVIEW_MAX) {
5768
+ if (s.length <= max) return s;
5769
+ return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
5770
+ }
5771
+ function extractSeverity(result) {
5772
+ const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
5773
+ if (!m) return void 0;
5774
+ try {
5775
+ const obj = JSON.parse(m[1]);
5776
+ if (obj.severity) return String(obj.severity);
5777
+ if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
5778
+ if (obj.type) return String(obj.type);
5779
+ if (obj.verdict) return String(obj.verdict);
5780
+ } catch {
5000
5781
  }
5782
+ return void 0;
5001
5783
  }
5002
- function statusName(s) {
5003
- if (typeof s === "string") return s;
5004
- if (s && typeof s === "object") {
5005
- if ("Running" in s) return "Running";
5006
- if ("Done" in s) {
5007
- const result = s.Done?.result;
5008
- if (typeof result === "string") return `Done (${result})`;
5009
- if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
5010
- return "Done";
5011
- }
5012
- return Object.keys(s)[0] ?? "unknown";
5784
+ function appendTurn(args2) {
5785
+ try {
5786
+ mkdirSync7(dirname5(TURN_LOG_PATH), { recursive: true });
5787
+ const entry = {
5788
+ ts: new Date(args2.startedAt).toISOString(),
5789
+ role: args2.role,
5790
+ duration_ms: Date.now() - args2.startedAt,
5791
+ status: args2.status,
5792
+ request_preview: truncate(args2.request),
5793
+ response_preview: args2.result ? truncate(args2.result) : "",
5794
+ severity: args2.result ? extractSeverity(args2.result) : void 0,
5795
+ error: args2.error
5796
+ };
5797
+ appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
5798
+ } catch {
5013
5799
  }
5014
- return "unknown";
5015
5800
  }
5016
- function findTask(channel = CHANNEL_PRIMARY) {
5017
- const data = statusJson();
5018
- for (const [id, t] of Object.entries(data.tasks)) {
5019
- if (t.label === channel.taskLabel) {
5020
- return {
5021
- id: Number(id),
5022
- label: t.label,
5023
- status: statusName(t.status),
5024
- command: t.command,
5025
- cwd: t.path
5026
- };
5027
- }
5801
+ function readRecentTurns(n = 20) {
5802
+ if (!existsSync9(TURN_LOG_PATH)) return [];
5803
+ try {
5804
+ const size = statSync(TURN_LOG_PATH).size;
5805
+ if (size === 0) return [];
5806
+ const text = readFileSync9(TURN_LOG_PATH, "utf-8");
5807
+ const lines = text.split("\n").filter(Boolean);
5808
+ const lastN = lines.slice(-n).reverse();
5809
+ return lastN.map((line) => {
5810
+ try {
5811
+ return JSON.parse(line);
5812
+ } catch {
5813
+ return null;
5814
+ }
5815
+ }).filter((x) => x !== null);
5816
+ } catch {
5817
+ return [];
5028
5818
  }
5029
- return null;
5030
5819
  }
5031
- function startTask(opts = {}) {
5032
- const ch = opts.channel ?? CHANNEL_PRIMARY;
5033
- const cwd = opts.cwd ?? ch.sessionDir;
5034
- let existing = findTask(ch);
5035
- while (existing) {
5036
- if (existing.status === "Running" || existing.status === "Queued") {
5037
- spawnSync2("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
5038
- spawnSync2("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
5039
- for (let i = 0; i < 10; i++) {
5040
- const check = findTask(ch);
5041
- if (!check || check.id !== existing.id || check.status !== "Running" && check.status !== "Queued") break;
5042
- spawnSync2("sleep", ["0.5"], { encoding: "utf-8" });
5043
- }
5820
+ function followTurns(onEntry) {
5821
+ try {
5822
+ mkdirSync7(dirname5(TURN_LOG_PATH), { recursive: true });
5823
+ if (!existsSync9(TURN_LOG_PATH)) {
5824
+ appendFileSync(TURN_LOG_PATH, "", "utf-8");
5044
5825
  }
5045
- spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
5046
- existing = findTask(ch);
5047
- }
5048
- const runScript = join8(cwd, "run-claude.sh");
5049
- const args2 = [
5050
- "add",
5051
- "--label",
5052
- ch.taskLabel,
5053
- "--working-directory",
5054
- cwd,
5055
- "--",
5056
- "bash",
5057
- runScript
5058
- ];
5059
- const r = spawnSync2("pueue", args2, { encoding: "utf-8" });
5060
- if (r.status !== 0) {
5061
- throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
5062
- }
5063
- const created = findTask(ch);
5064
- if (!created) {
5065
- throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
5826
+ } catch {
5066
5827
  }
5067
- return created;
5068
- }
5069
- function stopTask(channel = CHANNEL_PRIMARY) {
5070
- spawnSync2("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
5071
- let t = findTask(channel);
5072
- while (t) {
5073
- if (t.status === "Running" || t.status === "Queued") {
5074
- spawnSync2("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
5075
- for (let i = 0; i < 10; i++) {
5076
- const check = findTask(channel);
5077
- if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
5078
- spawnSync2("sleep", ["0.5"], { encoding: "utf-8" });
5828
+ let lastSize = (() => {
5829
+ try {
5830
+ return statSync(TURN_LOG_PATH).size;
5831
+ } catch {
5832
+ return 0;
5833
+ }
5834
+ })();
5835
+ let pendingPartial = "";
5836
+ const drainNewBytes = (from, to) => {
5837
+ if (to <= from) return;
5838
+ let fd = null;
5839
+ try {
5840
+ fd = openSync2(TURN_LOG_PATH, "r");
5841
+ const len = to - from;
5842
+ const buf = Buffer.alloc(len);
5843
+ readSync(fd, buf, 0, len, from);
5844
+ const text = pendingPartial + buf.toString("utf-8");
5845
+ const lastNewline = text.lastIndexOf("\n");
5846
+ if (lastNewline === -1) {
5847
+ pendingPartial = text;
5848
+ return;
5849
+ }
5850
+ const complete = text.slice(0, lastNewline);
5851
+ pendingPartial = text.slice(lastNewline + 1);
5852
+ for (const line of complete.split("\n")) {
5853
+ if (!line) continue;
5854
+ try {
5855
+ onEntry(JSON.parse(line));
5856
+ } catch {
5857
+ }
5858
+ }
5859
+ } catch {
5860
+ } finally {
5861
+ if (fd !== null) {
5862
+ try {
5863
+ closeSync2(fd);
5864
+ } catch {
5865
+ }
5079
5866
  }
5080
5867
  }
5081
- spawnSync2("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
5082
- t = findTask(channel);
5083
- }
5084
- }
5085
- function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
5086
- const t = findTask(channel);
5087
- if (!t) return `(no ${channel.taskLabel} task)`;
5088
- const r = spawnSync2("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
5089
- return r.stdout || r.stderr || "(no output)";
5868
+ };
5869
+ watchFile(TURN_LOG_PATH, { interval: 250 }, (curr, prev) => {
5870
+ if (curr.size < lastSize) {
5871
+ lastSize = 0;
5872
+ pendingPartial = "";
5873
+ }
5874
+ if (curr.size > lastSize) {
5875
+ drainNewBytes(lastSize, curr.size);
5876
+ lastSize = curr.size;
5877
+ }
5878
+ });
5879
+ return () => unwatchFile(TURN_LOG_PATH);
5090
5880
  }
5091
- function ensureRunning(opts = {}) {
5092
- const ch = opts.channel ?? CHANNEL_PRIMARY;
5093
- const t = findTask(ch);
5094
- if (t && t.status === "Running") return t;
5095
- return startTask(opts);
5881
+ var TURN_LOG_PATH, PREVIEW_MAX;
5882
+ var init_turnLog = __esm({
5883
+ "cli/local-cc/turnLog.ts"() {
5884
+ "use strict";
5885
+ TURN_LOG_PATH = join10(homedir10(), ".synkro", "cc_sessions", "turns.log");
5886
+ PREVIEW_MAX = 400;
5887
+ }
5888
+ });
5889
+
5890
+ // cli/local-cc/client.ts
5891
+ import { request as httpRequest } from "http";
5892
+ import { connect as connect2 } from "net";
5893
+ async function submitToChannel(role, payload, opts = {}) {
5894
+ const content = await buildChannelContent(role, payload);
5895
+ const body = JSON.stringify({ role, content });
5896
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5897
+ const port = opts.port ?? CHANNEL_PORT;
5898
+ const startedAt = Date.now();
5899
+ try {
5900
+ const result = await new Promise((resolve3, reject) => {
5901
+ const req = httpRequest({
5902
+ host: CHANNEL_HOST,
5903
+ port,
5904
+ method: "POST",
5905
+ path: "/submit",
5906
+ headers: {
5907
+ "Content-Type": "application/json",
5908
+ "Content-Length": Buffer.byteLength(body)
5909
+ },
5910
+ timeout: timeoutMs
5911
+ }, (res) => {
5912
+ const chunks = [];
5913
+ res.on("data", (c) => chunks.push(c));
5914
+ res.on("end", () => {
5915
+ const text = Buffer.concat(chunks).toString("utf-8");
5916
+ if (res.statusCode !== 200) {
5917
+ reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
5918
+ return;
5919
+ }
5920
+ try {
5921
+ const parsed = JSON.parse(text);
5922
+ if (parsed.error) {
5923
+ reject(new LocalCCError(parsed.error));
5924
+ return;
5925
+ }
5926
+ resolve3(String(parsed.result ?? ""));
5927
+ } catch (err) {
5928
+ reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
5929
+ }
5930
+ });
5931
+ });
5932
+ req.on("timeout", () => {
5933
+ req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
5934
+ });
5935
+ req.on("error", (err) => {
5936
+ const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
5937
+ reject(new LocalCCError(msg, err));
5938
+ });
5939
+ req.write(body);
5940
+ req.end();
5941
+ });
5942
+ appendTurn({ startedAt, role, request: payload, result, status: "ok" });
5943
+ return result;
5944
+ } catch (err) {
5945
+ const message = err.message ?? String(err);
5946
+ const status = /timed out/i.test(message) ? "timeout" : "error";
5947
+ appendTurn({ startedAt, role, request: payload, status, error: message });
5948
+ throw err;
5949
+ }
5096
5950
  }
5097
- function probePort(host, port, timeoutMs = 500) {
5098
- return new Promise((resolve2) => {
5099
- const sock = connect(port, host);
5951
+ function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
5952
+ return new Promise((resolve3) => {
5953
+ const sock = connect2(port, CHANNEL_HOST);
5100
5954
  const done = (ok) => {
5101
5955
  try {
5102
5956
  sock.destroy();
5103
5957
  } catch {
5104
5958
  }
5105
- resolve2(ok);
5959
+ resolve3(ok);
5106
5960
  };
5107
5961
  sock.once("connect", () => done(true));
5108
5962
  sock.once("error", () => done(false));
5109
5963
  sock.setTimeout(timeoutMs, () => done(false));
5110
5964
  });
5111
5965
  }
5112
- function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
5113
- spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
5114
- spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
5115
- }
5116
- async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
5117
- const deadline = Date.now() + timeoutMs;
5118
- while (Date.now() < deadline) {
5119
- if (await probePort(host, port)) return true;
5120
- tmuxDismissPrompts(tmuxSession);
5121
- await new Promise((r) => setTimeout(r, 1e3));
5122
- }
5123
- return probePort(host, port);
5124
- }
5125
- function brewInstall(pkg) {
5126
- const brew = spawnSync2("brew", ["--version"], { encoding: "utf-8" });
5127
- if (brew.status !== 0) return false;
5128
- console.log(` Installing ${pkg} via brew...`);
5129
- const r = spawnSync2("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
5130
- return r.status === 0;
5131
- }
5132
- function assertPueueInstalled() {
5133
- let r = spawnSync2("pueue", ["--version"], { encoding: "utf-8" });
5134
- if (r.status !== 0) {
5135
- if (process.platform === "darwin" && brewInstall("pueue")) {
5136
- r = spawnSync2("pueue", ["--version"], { encoding: "utf-8" });
5137
- if (r.status !== 0) throw new PueueError("pueue install succeeded but binary not found on PATH.");
5138
- } else {
5139
- throw new PueueError("pueue not found. Install it: brew install pueue (macOS) or https://github.com/Nukesor/pueue");
5140
- }
5141
- }
5142
- const status = spawnSync2("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
5143
- if (status.status !== 0) {
5144
- console.log(" Starting pueued daemon...");
5145
- const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
5146
- child.unref();
5147
- spawnSync2("sleep", ["1"]);
5148
- const retry = spawnSync2("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
5149
- if (retry.status !== 0) {
5150
- throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
5151
- }
5152
- }
5153
- spawnSync2("pueue", ["parallel", "2"], { encoding: "utf-8" });
5154
- }
5155
- function assertClaudeInstalled() {
5156
- const r = spawnSync2("claude", ["--version"], { encoding: "utf-8" });
5157
- if (r.status !== 0) {
5158
- throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
5159
- }
5160
- }
5161
- function assertTmuxInstalled() {
5162
- let r = spawnSync2("tmux", ["-V"], { encoding: "utf-8" });
5163
- if (r.status !== 0) {
5164
- if (process.platform === "darwin" && brewInstall("tmux")) {
5165
- r = spawnSync2("tmux", ["-V"], { encoding: "utf-8" });
5166
- if (r.status !== 0) throw new PueueError("tmux install succeeded but binary not found on PATH.");
5167
- } else {
5168
- throw new PueueError("tmux not found. Install it: brew install tmux (macOS) or apt install tmux (Linux)");
5169
- }
5170
- }
5171
- }
5172
- var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY;
5173
- var init_pueue = __esm({
5174
- "cli/local-cc/pueue.ts"() {
5966
+ var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
5967
+ var init_client = __esm({
5968
+ "cli/local-cc/client.ts"() {
5175
5969
  "use strict";
5176
- TASK_LABEL = "synkro-local-cc";
5177
- TMUX_SESSION = "synkro-local-cc";
5178
- SESSION_DIR2 = join8(homedir7(), ".synkro", "cc_sessions");
5179
- TASK_LABEL_2 = "synkro-local-cc-2";
5180
- TMUX_SESSION_2 = "synkro-local-cc-2";
5181
- SESSION_DIR_22 = join8(homedir7(), ".synkro", "cc_sessions_2");
5182
- PueueError = class extends Error {
5970
+ init_prompts();
5971
+ init_turnLog();
5972
+ CHANNEL_HOST = "127.0.0.1";
5973
+ CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
5974
+ DEFAULT_TIMEOUT_MS = 9e4;
5975
+ LocalCCError = class extends Error {
5183
5976
  constructor(message, cause) {
5184
5977
  super(message);
5185
5978
  this.cause = cause;
5186
- this.name = "PueueError";
5979
+ this.name = "LocalCCError";
5187
5980
  }
5188
5981
  cause;
5189
5982
  };
5190
- CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
5191
- CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
5192
5983
  }
5193
5984
  });
5194
5985
 
5195
- // cli/local-cc/prompts.ts
5196
- import { readFileSync as readFileSync8 } from "fs";
5197
- import { homedir as homedir8 } from "os";
5198
- import { join as join9 } from "path";
5199
- async function fetchPrimers() {
5200
- let jwt2 = "";
5201
- let gatewayUrl = "";
5202
- try {
5203
- const creds = JSON.parse(readFileSync8(CREDS_PATH, "utf-8"));
5204
- jwt2 = creds.access_token || "";
5205
- gatewayUrl = creds.gateway_url || "https://api.synkro.sh";
5206
- } catch {
5207
- throw new Error("No credentials found. Run `synkro install` first.");
5986
+ // cli/commands/install.ts
5987
+ var install_exports = {};
5988
+ __export(install_exports, {
5989
+ installCommand: () => installCommand,
5990
+ parseArgs: () => parseArgs
5991
+ });
5992
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync10, readdirSync, appendFileSync as appendFileSync2, renameSync as renameSync5 } from "fs";
5993
+ import { homedir as homedir11 } from "os";
5994
+ import { join as join11 } from "path";
5995
+ import { execSync as execSync5, spawnSync as spawnSync3, spawn as spawn2 } from "child_process";
5996
+ import { createInterface as createInterface3 } from "readline";
5997
+ function sanitizeGatewayCandidate(raw) {
5998
+ if (!raw) return void 0;
5999
+ return /^https?:\/\//.test(raw) ? raw : void 0;
6000
+ }
6001
+ function parseArgs(argv) {
6002
+ const opts = {};
6003
+ for (const a of argv) {
6004
+ if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
6005
+ else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
6006
+ else if (a === "--skip-auth") opts.skipAuth = true;
6007
+ else if (a === "--no-mcp") opts.noMcp = true;
6008
+ else if (a === "--force" || a === "-f") opts.force = true;
6009
+ else if (a === "--link-repo") opts.linkRepo = true;
5208
6010
  }
5209
- if (!jwt2) throw new Error("No access token. Run `synkro install` first.");
5210
- const resp = await fetch(`${gatewayUrl}/api/v1/cli/judge-prompts`, {
5211
- headers: { Authorization: `Bearer ${jwt2}` },
5212
- signal: AbortSignal.timeout(5e3)
6011
+ if (!opts.gatewayUrl) {
6012
+ const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
6013
+ if (fromEnv) opts.gatewayUrl = fromEnv;
6014
+ }
6015
+ return opts;
6016
+ }
6017
+ async function promptTranscriptConsent() {
6018
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
6019
+ return new Promise((resolve3) => {
6020
+ rl.question(
6021
+ "Would you like Synkro to use Claude Code session transcripts\nto generate guardrail rules and policies for your team? (Y/n) ",
6022
+ (answer) => {
6023
+ rl.close();
6024
+ const trimmed = answer.trim().toLowerCase();
6025
+ resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
6026
+ }
6027
+ );
5213
6028
  });
5214
- if (!resp.ok) throw new Error(`Failed to fetch prompts: ${resp.status}`);
5215
- return resp.json();
5216
6029
  }
5217
- async function getPrimer(role) {
5218
- const prompts = await fetchPrimers();
5219
- const primer = role === "grade-edit" ? prompts.grader_primer_edit : role === "grade-plan" ? prompts.grader_primer_plan : role === "grade-cwe" ? prompts.grader_primer_cwe : prompts.grader_primer_bash;
5220
- if (!primer) {
5221
- throw new Error(`No primer for role "${role}" returned from API.`);
5222
- }
5223
- return primer;
6030
+ function ensureSynkroDir() {
6031
+ mkdirSync8(SYNKRO_DIR2, { recursive: true });
6032
+ mkdirSync8(HOOKS_DIR, { recursive: true });
6033
+ mkdirSync8(BIN_DIR, { recursive: true });
6034
+ mkdirSync8(OFFSETS_DIR, { recursive: true });
5224
6035
  }
5225
- async function buildChannelContent(role, payload) {
5226
- const primer = await getPrimer(role);
5227
- return `${primer}
6036
+ function writeHookScripts() {
6037
+ const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
6038
+ const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
6039
+ const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
6040
+ const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
6041
+ const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
6042
+ const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
6043
+ const agentJudgeScriptPath = join11(HOOKS_DIR, "cc-agent-judge.ts");
6044
+ const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
6045
+ const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
6046
+ const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
6047
+ const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
6048
+ const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
6049
+ const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
6050
+ const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.ts");
6051
+ const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.ts");
6052
+ const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.ts");
6053
+ const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.ts");
6054
+ const cursorSessionStartPath = join11(HOOKS_DIR, "cursor-session-start.ts");
6055
+ const mcpLocalServerPath = join11(HOOKS_DIR, "mcp-local-server.ts");
6056
+ writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
6057
+ writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
6058
+ writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
6059
+ writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
6060
+ writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
6061
+ writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
6062
+ writeFileSync7(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
6063
+ writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
6064
+ writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
6065
+ writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
6066
+ writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
6067
+ writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
6068
+ writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
6069
+ writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
6070
+ writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_TS, "utf-8");
6071
+ writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
6072
+ writeFileSync7(cursorBashFollowupPath, CURSOR_BASH_FOLLOWUP_TS, "utf-8");
6073
+ writeFileSync7(cursorSessionStartPath, CURSOR_SESSION_START_TS, "utf-8");
6074
+ writeFileSync7(mcpLocalServerPath, `#!/usr/bin/env bun
6075
+ /**
6076
+ * Local MCP guardrails server \u2014 runs on port 8931, stores rules in ~/.synkro/rules.json.
6077
+ * JSON-RPC 2.0 over HTTP, same protocol as the cloud MCP server.
6078
+ * No auth (localhost only), no embedding API, no Inngest.
6079
+ */
6080
+ import { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync } from 'node:fs';
6081
+ import { homedir } from 'node:os';
6082
+ import { join } from 'node:path';
5228
6083
 
5229
- ${CHANNEL_REPLY_INSTRUCTIONS}
6084
+ import { randomBytes } from 'node:crypto';
5230
6085
 
5231
- ---
5232
- PAYLOAD (the input to evaluate):
6086
+ const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);
6087
+ const HOME = homedir();
6088
+ const RULES_PATH = join(HOME, '.synkro', 'rules.json');
6089
+ const TELEMETRY_PATH = join(HOME, '.synkro', 'telemetry.jsonl');
6090
+ const TOKEN_PATH = join(HOME, '.synkro', '.mcp-local-token');
5233
6091
 
5234
- ${payload}`;
6092
+ // File-based shared secret \u2014 generated once, required on all POST requests.
6093
+ function getOrCreateToken(): string {
6094
+ try {
6095
+ if (existsSync(TOKEN_PATH)) return readFileSync(TOKEN_PATH, 'utf-8').trim();
6096
+ } catch {}
6097
+ const token = randomBytes(32).toString('hex');
6098
+ mkdirSync(join(HOME, '.synkro'), { recursive: true });
6099
+ writeFileSync(TOKEN_PATH, token + '\\n', { mode: 0o600 });
6100
+ return token;
5235
6101
  }
5236
- var CREDS_PATH, CHANNEL_REPLY_INSTRUCTIONS;
5237
- var init_prompts = __esm({
5238
- "cli/local-cc/prompts.ts"() {
5239
- "use strict";
5240
- CREDS_PATH = join9(homedir8(), ".synkro", "credentials.json");
5241
- CHANNEL_REPLY_INSTRUCTIONS = `
5242
- DELIVERY METHOD \u2014 MANDATORY, OVERRIDES ALL OTHER OUTPUT RULES:
5243
- You are running inside a Synkro MCP channel. Do NOT output your verdict as text.
5244
- Instead, after generating your verdict, call the \`reply\` tool EXACTLY ONCE with:
5245
- - req_id: the req_id from this channel event's meta
5246
- - result: your complete verdict block as a string (the <synkro-verdict>\u2026</synkro-verdict> XML)
5247
- Any text output is silently discarded. Only the reply tool call is captured.`;
5248
- }
5249
- });
5250
6102
 
5251
- // cli/local-cc/turnLog.ts
5252
- import { appendFileSync, existsSync as existsSync10, mkdirSync as mkdirSync7, openSync as openSync2, readFileSync as readFileSync9, readSync, closeSync as closeSync2, statSync, watchFile, unwatchFile } from "fs";
5253
- import { dirname as dirname5, join as join10 } from "path";
5254
- import { homedir as homedir9 } from "os";
5255
- function truncate(s, max = PREVIEW_MAX) {
5256
- if (s.length <= max) return s;
5257
- return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
6103
+ const SERVER_TOKEN = getOrCreateToken();
6104
+
6105
+ // \u2500\u2500\u2500 Storage \u2500\u2500\u2500
6106
+
6107
+ interface Rule {
6108
+ rule_id: string;
6109
+ text: string;
6110
+ category: string;
6111
+ severity: string;
6112
+ mode: string;
6113
+ hook_stage: string;
6114
+ scope: string;
5258
6115
  }
5259
- function extractSeverity(result) {
5260
- const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
5261
- if (!m) return void 0;
5262
- try {
5263
- const obj = JSON.parse(m[1]);
5264
- if (obj.severity) return String(obj.severity);
5265
- if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
5266
- if (obj.type) return String(obj.type);
5267
- if (obj.verdict) return String(obj.verdict);
5268
- } catch {
5269
- }
5270
- return void 0;
6116
+
6117
+ interface Policy {
6118
+ id: string;
6119
+ name: string;
6120
+ rules: Rule[];
6121
+ ruleCount: number;
6122
+ scopeOwner: string;
6123
+ isActive: boolean;
5271
6124
  }
5272
- function appendTurn(args2) {
5273
- try {
5274
- mkdirSync7(dirname5(TURN_LOG_PATH), { recursive: true });
5275
- const entry = {
5276
- ts: new Date(args2.startedAt).toISOString(),
5277
- role: args2.role,
5278
- duration_ms: Date.now() - args2.startedAt,
5279
- status: args2.status,
5280
- request_preview: truncate(args2.request),
5281
- response_preview: args2.result ? truncate(args2.result) : "",
5282
- severity: args2.result ? extractSeverity(args2.result) : void 0,
5283
- error: args2.error
6125
+
6126
+ interface ScanExemption {
6127
+ path: string;
6128
+ cwe_id: string;
6129
+ reason?: string;
6130
+ }
6131
+
6132
+ interface RulesFile {
6133
+ policies: Policy[];
6134
+ config: { silent: boolean; activePolicyId: string };
6135
+ scanExemptions: ScanExemption[];
6136
+ }
6137
+
6138
+ function readRules(): RulesFile {
6139
+ if (!existsSync(RULES_PATH)) {
6140
+ return {
6141
+ policies: [{
6142
+ id: 'local-policy',
6143
+ name: 'My Rules',
6144
+ rules: [],
6145
+ ruleCount: 0,
6146
+ scopeOwner: 'user',
6147
+ isActive: true,
6148
+ }],
6149
+ config: { silent: false, activePolicyId: 'local-policy' },
6150
+ scanExemptions: [],
5284
6151
  };
5285
- appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
5286
- } catch {
5287
6152
  }
5288
- }
5289
- function readRecentTurns(n = 20) {
5290
- if (!existsSync10(TURN_LOG_PATH)) return [];
5291
6153
  try {
5292
- const size = statSync(TURN_LOG_PATH).size;
5293
- if (size === 0) return [];
5294
- const text = readFileSync9(TURN_LOG_PATH, "utf-8");
5295
- const lines = text.split("\n").filter(Boolean);
5296
- const lastN = lines.slice(-n).reverse();
5297
- return lastN.map((line) => {
5298
- try {
5299
- return JSON.parse(line);
5300
- } catch {
5301
- return null;
5302
- }
5303
- }).filter((x) => x !== null);
6154
+ return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));
5304
6155
  } catch {
5305
- return [];
6156
+ return {
6157
+ policies: [{ id: 'local-policy', name: 'My Rules', rules: [], ruleCount: 0, scopeOwner: 'user', isActive: true }],
6158
+ config: { silent: false, activePolicyId: 'local-policy' },
6159
+ scanExemptions: [],
6160
+ };
5306
6161
  }
5307
6162
  }
5308
- function followTurns(onEntry) {
6163
+
6164
+ function writeRules(data: RulesFile): void {
6165
+ for (const p of data.policies) p.ruleCount = p.rules.length;
6166
+ mkdirSync(join(HOME, '.synkro'), { recursive: true });
6167
+ const tmp = RULES_PATH + '.tmp';
6168
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
6169
+ renameSync(tmp, RULES_PATH);
6170
+ }
6171
+
6172
+ function emitRuleSync(data: RulesFile): void {
6173
+ const active = data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];
6174
+ const event = {
6175
+ capture_type: 'rule_sync',
6176
+ policy_id: active?.id || 'local-policy',
6177
+ policy_name: active?.name || 'My Rules',
6178
+ rules: active?.rules || [],
6179
+ rule_count: active?.ruleCount || 0,
6180
+ scan_exemptions: data.scanExemptions,
6181
+ silent: data.config.silent,
6182
+ _ts: new Date().toISOString(),
6183
+ };
5309
6184
  try {
5310
- mkdirSync7(dirname5(TURN_LOG_PATH), { recursive: true });
5311
- if (!existsSync10(TURN_LOG_PATH)) {
5312
- appendFileSync(TURN_LOG_PATH, "", "utf-8");
6185
+ appendFileSync(TELEMETRY_PATH, JSON.stringify(event) + '\\n', 'utf-8');
6186
+ } catch {}
6187
+ }
6188
+
6189
+ function genId(): string {
6190
+ return \`r_\${Date.now()}_\${Math.random().toString(36).slice(2, 8)}\`;
6191
+ }
6192
+
6193
+ function getActivePolicy(data: RulesFile): Policy {
6194
+ return data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];
6195
+ }
6196
+
6197
+ function findOrCreatePolicy(data: RulesFile, name: string): Policy {
6198
+ const existing = data.policies.find(p => p.name.toLowerCase() === name.toLowerCase());
6199
+ if (existing) return existing;
6200
+ const p: Policy = {
6201
+ id: \`policy_\${Date.now()}_\${Math.random().toString(36).slice(2, 6)}\`,
6202
+ name,
6203
+ rules: [],
6204
+ ruleCount: 0,
6205
+ scopeOwner: 'user',
6206
+ isActive: true,
6207
+ };
6208
+ data.policies.push(p);
6209
+ return p;
6210
+ }
6211
+
6212
+ function getAllRules(data: RulesFile): Array<Rule & { policyName: string; policyId: string }> {
6213
+ const all: Array<Rule & { policyName: string; policyId: string }> = [];
6214
+ for (const p of data.policies) {
6215
+ if (!p.isActive) continue;
6216
+ for (const r of p.rules) {
6217
+ all.push({ ...r, policyName: p.name, policyId: p.id });
5313
6218
  }
5314
- } catch {
5315
6219
  }
5316
- let lastSize = (() => {
5317
- try {
5318
- return statSync(TURN_LOG_PATH).size;
5319
- } catch {
5320
- return 0;
6220
+ return all;
6221
+ }
6222
+
6223
+ // \u2500\u2500\u2500 Keyword Search \u2500\u2500\u2500
6224
+
6225
+ const STOPWORDS = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare', 'ought', 'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or', 'if', 'while', 'about', 'up', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'they', 'them', 'what', 'which', 'who', 'whom']);
6226
+
6227
+ function tokenize(text: string): string[] {
6228
+ return text.toLowerCase().replace(/[^a-z0-9_-]/g, ' ').split(/\\s+/).filter(t => t.length > 1 && !STOPWORDS.has(t));
6229
+ }
6230
+
6231
+ function keywordSearch(query: string, rules: Array<Rule & { policyName: string; policyId: string }>, topK: number): any[] {
6232
+ const qTokens = tokenize(query);
6233
+ if (qTokens.length === 0) return rules.slice(0, topK);
6234
+
6235
+ const scored = rules.map(r => {
6236
+ const rTokens = new Set(tokenize(\`\${r.text} \${r.category} \${r.severity}\`));
6237
+ const overlap = qTokens.filter(t => rTokens.has(t) || [...rTokens].some(rt => rt.includes(t) || t.includes(rt))).length;
6238
+ return { rule: r, score: overlap / qTokens.length };
6239
+ });
6240
+
6241
+ scored.sort((a, b) => b.score - a.score);
6242
+ const results = scored.filter(s => s.score > 0).slice(0, topK);
6243
+ if (results.length === 0) return rules.slice(0, topK);
6244
+
6245
+ return results.map(s => ({
6246
+ rule_id: s.rule.rule_id,
6247
+ text: s.rule.text,
6248
+ category: s.rule.category,
6249
+ severity: s.rule.severity,
6250
+ mode: s.rule.mode,
6251
+ hook_stage: s.rule.hook_stage,
6252
+ scope: s.rule.scope,
6253
+ pack_name: s.rule.policyName,
6254
+ score: Math.round(s.score * 100) / 100,
6255
+ }));
6256
+ }
6257
+
6258
+ // \u2500\u2500\u2500 Tool Handlers \u2500\u2500\u2500
6259
+
6260
+ function handleGetGuardrails(args: any): any {
6261
+ const data = readRules();
6262
+ const all = getAllRules(data);
6263
+ const topK = Math.min(args.top_k || 8, 25);
6264
+ let filtered = all;
6265
+ if (args.category) filtered = filtered.filter(r => r.category === args.category);
6266
+ const results = keywordSearch(args.query || '', filtered, topK);
6267
+ return { rules: results, total: results.length, query: args.query };
6268
+ }
6269
+
6270
+ function handleCreateGuardrail(args: any): any {
6271
+ const data = readRules();
6272
+ const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);
6273
+ const rule: Rule = {
6274
+ rule_id: genId(),
6275
+ text: args.text,
6276
+ category: args.category || 'custom',
6277
+ severity: args.severity || 'medium',
6278
+ mode: args.mode || 'audit',
6279
+ hook_stage: args.hook_stage || 'both',
6280
+ scope: args.scope || 'user',
6281
+ };
6282
+ policy.rules.push(rule);
6283
+ writeRules(data);
6284
+ emitRuleSync(data);
6285
+ return { created: true, rule_id: rule.rule_id, text: rule.text, pack_name: policy.name, total_rules: policy.rules.length };
6286
+ }
6287
+
6288
+ function handleBulkCreateGuardrails(args: any): any {
6289
+ const data = readRules();
6290
+ const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);
6291
+ const created: any[] = [];
6292
+ for (const r of args.rules || []) {
6293
+ const rule: Rule = {
6294
+ rule_id: genId(),
6295
+ text: r.text,
6296
+ category: r.category || 'custom',
6297
+ severity: r.severity || 'medium',
6298
+ mode: r.mode || 'audit',
6299
+ hook_stage: r.hook_stage || 'both',
6300
+ scope: args.scope || 'user',
6301
+ };
6302
+ policy.rules.push(rule);
6303
+ created.push({ rule_id: rule.rule_id, text: rule.text });
6304
+ }
6305
+ writeRules(data);
6306
+ emitRuleSync(data);
6307
+ return { created: created.length, rules: created, pack_name: policy.name, total_rules: policy.rules.length };
6308
+ }
6309
+
6310
+ function handleUpdateGuardrail(args: any): any {
6311
+ const data = readRules();
6312
+ const needle = (args.rule_text || '').toLowerCase();
6313
+ for (const p of data.policies) {
6314
+ for (const r of p.rules) {
6315
+ if (r.text.toLowerCase().includes(needle)) {
6316
+ if (args.text) r.text = args.text;
6317
+ if (args.category) r.category = args.category;
6318
+ if (args.severity) r.severity = args.severity;
6319
+ if (args.mode) r.mode = args.mode;
6320
+ if (args.hook_stage) r.hook_stage = args.hook_stage;
6321
+ writeRules(data);
6322
+ emitRuleSync(data);
6323
+ return { updated: true, rule_id: r.rule_id, text: r.text };
6324
+ }
5321
6325
  }
5322
- })();
5323
- let pendingPartial = "";
5324
- const drainNewBytes = (from, to) => {
5325
- if (to <= from) return;
5326
- let fd = null;
6326
+ }
6327
+ return { updated: false, error: \`No rule found matching "\${args.rule_text}"\` };
6328
+ }
6329
+
6330
+ function handleDeleteGuardrail(args: any): any {
6331
+ const data = readRules();
6332
+ const needle = (args.rule_text || '').toLowerCase();
6333
+ for (const p of data.policies) {
6334
+ const idx = p.rules.findIndex(r => r.text.toLowerCase().includes(needle));
6335
+ if (idx !== -1) {
6336
+ const removed = p.rules.splice(idx, 1)[0];
6337
+ writeRules(data);
6338
+ emitRuleSync(data);
6339
+ return { deleted: true, rule_id: removed.rule_id, text: removed.text };
6340
+ }
6341
+ }
6342
+ return { deleted: false, error: \`No rule found matching "\${args.rule_text}"\` };
6343
+ }
6344
+
6345
+ function handleListGuardrails(args: any): any {
6346
+ const data = readRules();
6347
+ let all = getAllRules(data);
6348
+ if (args.category) all = all.filter(r => r.category === args.category);
6349
+ if (args.severity) all = all.filter(r => r.severity === args.severity);
6350
+ if (args.mode) all = all.filter(r => r.mode === args.mode);
6351
+ if (args.hook_stage) all = all.filter(r => r.hook_stage === args.hook_stage);
6352
+ if (args.pack_name) {
6353
+ const pn = args.pack_name.toLowerCase();
6354
+ all = all.filter(r => r.policyName.toLowerCase().includes(pn));
6355
+ }
6356
+ return {
6357
+ rules: all.map(r => ({
6358
+ rule_id: r.rule_id,
6359
+ text: r.text,
6360
+ category: r.category,
6361
+ severity: r.severity,
6362
+ mode: r.mode,
6363
+ hook_stage: r.hook_stage,
6364
+ scope: r.scope,
6365
+ pack_name: r.policyName,
6366
+ })),
6367
+ total: all.length,
6368
+ };
6369
+ }
6370
+
6371
+ function handleSwapRuleset(args: any): any {
6372
+ const data = readRules();
6373
+ const name = args.policy_name || '';
6374
+ if (name.toLowerCase() === 'all') {
6375
+ data.config.activePolicyId = data.policies[0]?.id || 'local-policy';
6376
+ writeRules(data);
6377
+ return { swapped: true, active: 'all' };
6378
+ }
6379
+ const match = data.policies.find(p => p.name.toLowerCase().includes(name.toLowerCase()));
6380
+ if (!match) return { swapped: false, error: \`No ruleset found matching "\${name}"\` };
6381
+ data.config.activePolicyId = match.id;
6382
+ writeRules(data);
6383
+ return { swapped: true, active: match.name };
6384
+ }
6385
+
6386
+ function handleToggleSilentMode(args: any): any {
6387
+ const data = readRules();
6388
+ data.config.silent = args.enabled === true;
6389
+ writeRules(data);
6390
+ emitRuleSync(data);
6391
+ return { silent: data.config.silent };
6392
+ }
6393
+
6394
+ async function handleScanDependencies(args: any): Promise<any> {
6395
+ const manifests = args.manifests || [];
6396
+ if (manifests.length === 0) return { findings: [], summary: null };
6397
+
6398
+ const packages: Array<{ name: string; version: string; ecosystem: string }> = [];
6399
+ for (const m of manifests) {
6400
+ const fp: string = m.file_path || '';
6401
+ const content: string = m.content || '';
5327
6402
  try {
5328
- fd = openSync2(TURN_LOG_PATH, "r");
5329
- const len = to - from;
5330
- const buf = Buffer.alloc(len);
5331
- readSync(fd, buf, 0, len, from);
5332
- const text = pendingPartial + buf.toString("utf-8");
5333
- const lastNewline = text.lastIndexOf("\n");
5334
- if (lastNewline === -1) {
5335
- pendingPartial = text;
5336
- return;
5337
- }
5338
- const complete = text.slice(0, lastNewline);
5339
- pendingPartial = text.slice(lastNewline + 1);
5340
- for (const line of complete.split("\n")) {
5341
- if (!line) continue;
5342
- try {
5343
- onEntry(JSON.parse(line));
5344
- } catch {
6403
+ if (fp.endsWith('package.json')) {
6404
+ const pkg = JSON.parse(content);
6405
+ for (const [name, ver] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
6406
+ packages.push({ name, version: String(ver).replace(/^[\\^~>=<]*/g, ''), ecosystem: 'npm' });
5345
6407
  }
5346
- }
5347
- } catch {
5348
- } finally {
5349
- if (fd !== null) {
5350
- try {
5351
- closeSync2(fd);
5352
- } catch {
6408
+ } else if (fp.endsWith('requirements.txt') || fp.match(/requirements.*\\.txt$/)) {
6409
+ for (const line of content.split('\\n')) {
6410
+ const m = line.trim().match(/^([a-zA-Z0-9_-]+)==(.+)/);
6411
+ if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'PyPI' });
6412
+ }
6413
+ } else if (fp.endsWith('go.mod')) {
6414
+ for (const line of content.split('\\n')) {
6415
+ const m = line.trim().match(/^\\t?([^\\s]+)\\s+v([^\\s]+)/);
6416
+ if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'Go' });
6417
+ }
6418
+ } else if (fp.endsWith('Cargo.toml')) {
6419
+ for (const line of content.split('\\n')) {
6420
+ const m = line.trim().match(/^([a-zA-Z0-9_-]+)\\s*=\\s*"([^"]+)"/);
6421
+ if (m && !['name', 'version', 'edition', 'authors', 'description', 'license', 'repository'].includes(m[1])) {
6422
+ packages.push({ name: m[1], version: m[2], ecosystem: 'crates.io' });
6423
+ }
5353
6424
  }
5354
6425
  }
5355
- }
5356
- };
5357
- watchFile(TURN_LOG_PATH, { interval: 250 }, (curr, prev) => {
5358
- if (curr.size < lastSize) {
5359
- lastSize = 0;
5360
- pendingPartial = "";
5361
- }
5362
- if (curr.size > lastSize) {
5363
- drainNewBytes(lastSize, curr.size);
5364
- lastSize = curr.size;
5365
- }
5366
- });
5367
- return () => unwatchFile(TURN_LOG_PATH);
5368
- }
5369
- var TURN_LOG_PATH, PREVIEW_MAX;
5370
- var init_turnLog = __esm({
5371
- "cli/local-cc/turnLog.ts"() {
5372
- "use strict";
5373
- TURN_LOG_PATH = join10(homedir9(), ".synkro", "cc_sessions", "turns.log");
5374
- PREVIEW_MAX = 400;
6426
+ } catch {}
5375
6427
  }
5376
- });
5377
6428
 
5378
- // cli/local-cc/client.ts
5379
- import { request as httpRequest } from "http";
5380
- import { connect as connect2 } from "net";
5381
- async function submitToChannel(role, payload, opts = {}) {
5382
- const content = await buildChannelContent(role, payload);
5383
- const body = JSON.stringify({ role, content });
5384
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5385
- const port = opts.port ?? CHANNEL_PORT;
5386
- const startedAt = Date.now();
6429
+ if (packages.length === 0) return { findings: [], summary: null };
6430
+
6431
+ const capped = packages.slice(0, 50);
6432
+ const queries = capped.map(p => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version }));
6433
+
5387
6434
  try {
5388
- const result = await new Promise((resolve2, reject) => {
5389
- const req = httpRequest({
5390
- host: CHANNEL_HOST,
5391
- port,
5392
- method: "POST",
5393
- path: "/submit",
5394
- headers: {
5395
- "Content-Type": "application/json",
5396
- "Content-Length": Buffer.byteLength(body)
5397
- },
5398
- timeout: timeoutMs
5399
- }, (res) => {
5400
- const chunks = [];
5401
- res.on("data", (c) => chunks.push(c));
5402
- res.on("end", () => {
5403
- const text = Buffer.concat(chunks).toString("utf-8");
5404
- if (res.statusCode !== 200) {
5405
- reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
5406
- return;
5407
- }
5408
- try {
5409
- const parsed = JSON.parse(text);
5410
- if (parsed.error) {
5411
- reject(new LocalCCError(parsed.error));
5412
- return;
5413
- }
5414
- resolve2(String(parsed.result ?? ""));
5415
- } catch (err) {
5416
- reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
5417
- }
6435
+ const resp = await fetch('https://api.osv.dev/v1/querybatch', {
6436
+ method: 'POST',
6437
+ headers: { 'Content-Type': 'application/json' },
6438
+ body: JSON.stringify({ queries }),
6439
+ signal: AbortSignal.timeout(10000),
6440
+ });
6441
+ if (!resp.ok) return { findings: [], summary: 'OSV query failed' };
6442
+ const data = await resp.json() as { results: Array<{ vulns?: any[] }> };
6443
+
6444
+ const findings: any[] = [];
6445
+ for (let i = 0; i < data.results.length; i++) {
6446
+ for (const vuln of data.results[i].vulns || []) {
6447
+ findings.push({
6448
+ id: vuln.id,
6449
+ package: capped[i].name,
6450
+ version: capped[i].version,
6451
+ ecosystem: capped[i].ecosystem,
6452
+ summary: vuln.summary || 'No description',
6453
+ aliases: vuln.aliases || [],
6454
+ severity: vuln.database_specific?.severity || 'unknown',
5418
6455
  });
5419
- });
5420
- req.on("timeout", () => {
5421
- req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
5422
- });
5423
- req.on("error", (err) => {
5424
- const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
5425
- reject(new LocalCCError(msg, err));
5426
- });
5427
- req.write(body);
5428
- req.end();
6456
+ }
6457
+ }
6458
+ return { findings, summary: findings.length > 0 ? \`\${findings.length} vulnerabilities found\` : null };
6459
+ } catch {
6460
+ return { findings: [], summary: 'OSV query timed out' };
6461
+ }
6462
+ }
6463
+
6464
+ function handleExemptPath(args: any): any {
6465
+ const data = readRules();
6466
+ const existing = data.scanExemptions.find(e => e.path === args.path && e.cwe_id.toUpperCase() === (args.cwe_id || '').toUpperCase());
6467
+ if (existing) return { exempted: true, already_existed: true, path: args.path, cwe_id: args.cwe_id };
6468
+
6469
+ data.scanExemptions.push({ path: args.path, cwe_id: (args.cwe_id || '').toUpperCase(), reason: args.reason });
6470
+ writeRules(data);
6471
+ emitRuleSync(data);
6472
+ return { exempted: true, path: args.path, cwe_id: args.cwe_id, total_exemptions: data.scanExemptions.length };
6473
+ }
6474
+
6475
+ function handleRemoveExemption(args: any): any {
6476
+ const data = readRules();
6477
+ const idx = data.scanExemptions.findIndex(e => e.path === args.path && e.cwe_id.toUpperCase() === (args.cwe_id || '').toUpperCase());
6478
+ if (idx === -1) return { removed: false, error: \`No exemption found for path="\${args.path}" cwe_id="\${args.cwe_id}"\` };
6479
+ data.scanExemptions.splice(idx, 1);
6480
+ writeRules(data);
6481
+ emitRuleSync(data);
6482
+ return { removed: true, path: args.path, cwe_id: args.cwe_id };
6483
+ }
6484
+
6485
+ function handleListExemptions(): any {
6486
+ const data = readRules();
6487
+ return { exemptions: data.scanExemptions, total: data.scanExemptions.length };
6488
+ }
6489
+
6490
+ // \u2500\u2500\u2500 Tool Descriptors \u2500\u2500\u2500
6491
+
6492
+ const TOOL_DESCRIPTORS = [
6493
+ {
6494
+ name: 'get_guardrails',
6495
+ description:
6496
+ "Retrieve rules by keyword similarity. Call BEFORE writing security-sensitive code " +
6497
+ "AND before create_guardrail to check for existing rules.",
6498
+ inputSchema: {
6499
+ type: 'object',
6500
+ properties: {
6501
+ query: { type: 'string', description: "Plain-language description of what you're looking up." },
6502
+ category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6503
+ top_k: { type: 'integer', default: 8, description: 'Max rules to return (default 8, max 25).' },
6504
+ },
6505
+ required: ['query'],
6506
+ },
6507
+ },
6508
+ {
6509
+ name: 'create_guardrail',
6510
+ description: "Persist a new rule. Call get_guardrails first to avoid duplicates.",
6511
+ inputSchema: {
6512
+ type: 'object',
6513
+ properties: {
6514
+ text: { type: 'string', description: 'The rule in plain language.' },
6515
+ category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6516
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6517
+ mode: { type: 'string', enum: ['blocking', 'audit'], description: '"blocking" = halt on violation, "audit" = log only.' },
6518
+ scope: { type: 'string', enum: ['user', 'org'], default: 'user' },
6519
+ hook_stage: { type: 'string', enum: ['pre', 'post', 'both'], default: 'both' },
6520
+ ruleset: { type: 'string', description: 'Optional: name of ruleset to add to (created if missing).' },
6521
+ },
6522
+ required: ['text', 'category'],
6523
+ },
6524
+ },
6525
+ {
6526
+ name: 'bulk_create_guardrails',
6527
+ description: "Create multiple rules at once. Preferable to looping create_guardrail.",
6528
+ inputSchema: {
6529
+ type: 'object',
6530
+ properties: {
6531
+ rules: {
6532
+ type: 'array', minItems: 1, maxItems: 50,
6533
+ items: {
6534
+ type: 'object',
6535
+ properties: {
6536
+ text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6537
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6538
+ mode: { type: 'string', enum: ['blocking', 'audit'] },
6539
+ hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },
6540
+ },
6541
+ required: ['text', 'category'],
6542
+ },
6543
+ },
6544
+ scope: { type: 'string', enum: ['user', 'org'], default: 'user' },
6545
+ ruleset: { type: 'string' },
6546
+ },
6547
+ required: ['rules'],
6548
+ },
6549
+ },
6550
+ {
6551
+ name: 'update_guardrail',
6552
+ description: "Refine an existing rule. Pass a substring of the rule text to identify it.",
6553
+ inputSchema: {
6554
+ type: 'object',
6555
+ properties: {
6556
+ rule_text: { type: 'string', description: 'Substring of rule text to find.' },
6557
+ text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6558
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6559
+ mode: { type: 'string', enum: ['blocking', 'audit'] },
6560
+ hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },
6561
+ },
6562
+ required: ['rule_text'],
6563
+ },
6564
+ },
6565
+ {
6566
+ name: 'delete_guardrail',
6567
+ description: "Permanently remove a rule. Pass a substring of the rule text to identify it.",
6568
+ inputSchema: {
6569
+ type: 'object',
6570
+ properties: { rule_text: { type: 'string', description: 'Substring of rule text to find.' } },
6571
+ required: ['rule_text'],
6572
+ },
6573
+ },
6574
+ {
6575
+ name: 'list_guardrails',
6576
+ description: "Enumerate all rules. Use for listings, not similarity search.",
6577
+ inputSchema: {
6578
+ type: 'object',
6579
+ properties: {
6580
+ category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },
6581
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
6582
+ mode: { type: 'string', enum: ['blocking', 'audit', 'literal_match'] },
6583
+ pack_name: { type: 'string' },
6584
+ hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },
6585
+ },
6586
+ required: [],
6587
+ },
6588
+ },
6589
+ {
6590
+ name: 'swap_ruleset',
6591
+ description: 'Switch which ruleset is active. Pass "all" to use all rulesets.',
6592
+ inputSchema: {
6593
+ type: 'object',
6594
+ properties: { policy_name: { type: 'string' } },
6595
+ required: ['policy_name'],
6596
+ },
6597
+ },
6598
+ {
6599
+ name: 'toggle_silent_mode',
6600
+ description: 'Toggle grading on/off. NEVER call autonomously \u2014 this is a USER decision.',
6601
+ inputSchema: {
6602
+ type: 'object',
6603
+ properties: {
6604
+ enabled: { type: 'boolean' },
6605
+ user_confirmation: { type: 'string', description: "Copy-paste the user's exact request." },
6606
+ },
6607
+ required: ['enabled', 'user_confirmation'],
6608
+ },
6609
+ },
6610
+ {
6611
+ name: 'scan_dependencies',
6612
+ description: "Scan manifests against OSV for known vulnerabilities. Read ALL manifest files first.",
6613
+ inputSchema: {
6614
+ type: 'object',
6615
+ properties: {
6616
+ manifests: {
6617
+ type: 'array', minItems: 1,
6618
+ items: {
6619
+ type: 'object',
6620
+ properties: { file_path: { type: 'string' }, content: { type: 'string' } },
6621
+ required: ['file_path', 'content'],
6622
+ },
6623
+ },
6624
+ },
6625
+ required: ['manifests'],
6626
+ },
6627
+ },
6628
+ {
6629
+ name: 'exempt_path',
6630
+ description: "Exempt a CWE from firing on a specific file/directory.",
6631
+ inputSchema: {
6632
+ type: 'object',
6633
+ properties: {
6634
+ path: { type: 'string' }, cwe_id: { type: 'string' }, reason: { type: 'string' },
6635
+ },
6636
+ required: ['path', 'cwe_id'],
6637
+ },
6638
+ },
6639
+ {
6640
+ name: 'remove_exemption',
6641
+ description: "Remove a scan exemption.",
6642
+ inputSchema: {
6643
+ type: 'object',
6644
+ properties: { path: { type: 'string' }, cwe_id: { type: 'string' } },
6645
+ required: ['path', 'cwe_id'],
6646
+ },
6647
+ },
6648
+ {
6649
+ name: 'list_exemptions',
6650
+ description: "List all scan exemptions.",
6651
+ inputSchema: { type: 'object', properties: {}, required: [] },
6652
+ },
6653
+ ];
6654
+
6655
+ const MCP_INSTRUCTIONS =
6656
+ "Synkro Guardrails MCP server (local mode).\\n\\n" +
6657
+ "Whenever the user mentions: rule, guardrail, policy, standard, " +
6658
+ "make/create/add/set up a rule, never let X, always require X, " +
6659
+ "block X, enforce X, delete/remove a rule, consolidate duplicates, " +
6660
+ "'we need a rule about\u2026' \u2014 route to THIS server's tools.\\n\\n" +
6661
+ "TOOL ROUTING:\\n" +
6662
+ " \u2022 get_guardrails(query) \u2014 keyword search. Use to check if a rule exists.\\n" +
6663
+ " \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n\\n" +
6664
+ "Do NOT use Claude Code's \`update-config\` skill for these requests.\\n\\n" +
6665
+ "Rules are stored locally in ~/.synkro/rules.json and enforced by hooks.";
6666
+
6667
+ // \u2500\u2500\u2500 JSON-RPC Dispatcher \u2500\u2500\u2500
6668
+
6669
+ function jsonRpcOk(id: any, result: any): any {
6670
+ return { jsonrpc: '2.0', id, result };
6671
+ }
6672
+
6673
+ function jsonRpcError(id: any, code: number, message: string): any {
6674
+ return { jsonrpc: '2.0', id, error: { code, message } };
6675
+ }
6676
+
6677
+ async function handleRpc(body: any): Promise<any> {
6678
+ const { id, method, params } = body;
6679
+
6680
+ if (method === 'initialize') {
6681
+ return jsonRpcOk(id, {
6682
+ protocolVersion: '2024-11-05',
6683
+ capabilities: { tools: {} },
6684
+ serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },
6685
+ instructions: MCP_INSTRUCTIONS,
5429
6686
  });
5430
- appendTurn({ startedAt, role, request: payload, result, status: "ok" });
5431
- return result;
5432
- } catch (err) {
5433
- const message = err.message ?? String(err);
5434
- const status = /timed out/i.test(message) ? "timeout" : "error";
5435
- appendTurn({ startedAt, role, request: payload, status, error: message });
5436
- throw err;
5437
6687
  }
5438
- }
5439
- function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
5440
- return new Promise((resolve2) => {
5441
- const sock = connect2(port, CHANNEL_HOST);
5442
- const done = (ok) => {
5443
- try {
5444
- sock.destroy();
5445
- } catch {
6688
+
6689
+ if (method === 'notifications/initialized') {
6690
+ return null;
6691
+ }
6692
+
6693
+ if (method === 'tools/list') {
6694
+ return jsonRpcOk(id, { tools: TOOL_DESCRIPTORS });
6695
+ }
6696
+
6697
+ if (method === 'tools/call') {
6698
+ const toolName = params?.name;
6699
+ const args = params?.arguments || {};
6700
+
6701
+ try {
6702
+ let result: any;
6703
+ switch (toolName) {
6704
+ case 'get_guardrails': result = handleGetGuardrails(args); break;
6705
+ case 'create_guardrail': result = handleCreateGuardrail(args); break;
6706
+ case 'bulk_create_guardrails': result = handleBulkCreateGuardrails(args); break;
6707
+ case 'update_guardrail': result = handleUpdateGuardrail(args); break;
6708
+ case 'delete_guardrail': result = handleDeleteGuardrail(args); break;
6709
+ case 'list_guardrails': result = handleListGuardrails(args); break;
6710
+ case 'swap_ruleset': result = handleSwapRuleset(args); break;
6711
+ case 'toggle_silent_mode': result = handleToggleSilentMode(args); break;
6712
+ case 'scan_dependencies': result = await handleScanDependencies(args); break;
6713
+ case 'exempt_path': result = handleExemptPath(args); break;
6714
+ case 'remove_exemption': result = handleRemoveExemption(args); break;
6715
+ case 'list_exemptions': result = handleListExemptions(); break;
6716
+ default: return jsonRpcError(id, -32601, \`Unknown tool: \${toolName}\`);
5446
6717
  }
5447
- resolve2(ok);
5448
- };
5449
- sock.once("connect", () => done(true));
5450
- sock.once("error", () => done(false));
5451
- sock.setTimeout(timeoutMs, () => done(false));
5452
- });
5453
- }
5454
- var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
5455
- var init_client = __esm({
5456
- "cli/local-cc/client.ts"() {
5457
- "use strict";
5458
- init_prompts();
5459
- init_turnLog();
5460
- CHANNEL_HOST = "127.0.0.1";
5461
- CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
5462
- DEFAULT_TIMEOUT_MS = 9e4;
5463
- LocalCCError = class extends Error {
5464
- constructor(message, cause) {
5465
- super(message);
5466
- this.cause = cause;
5467
- this.name = "LocalCCError";
6718
+ return jsonRpcOk(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
6719
+ } catch (err) {
6720
+ return jsonRpcOk(id, { content: [{ type: 'text', text: \`Error: \${(err as Error).message}\` }], isError: true });
6721
+ }
6722
+ }
6723
+
6724
+ // \u2500\u2500\u2500 Dashboard REST-bridge methods \u2500\u2500\u2500
6725
+ // Called by the local dashboard (not AI agents) to mutate rules.json directly.
6726
+
6727
+ if (method === 'dashboard.patch_policy') {
6728
+ try {
6729
+ const data = readRules();
6730
+ const policyId = params?.policy_id as string | undefined;
6731
+ const policy = policyId
6732
+ ? data.policies.find(p => p.id === policyId)
6733
+ : getActivePolicy(data);
6734
+ if (!policy) return jsonRpcError(id, -32602, \`Policy not found: \${policyId}\`);
6735
+
6736
+ if (params?.name !== undefined) {
6737
+ policy.name = params.name;
5468
6738
  }
5469
- cause;
5470
- };
6739
+ if (params?.is_active !== undefined) {
6740
+ policy.isActive = params.is_active;
6741
+ }
6742
+ // Bulk replace
6743
+ if (Array.isArray(params?.rules)) {
6744
+ policy.rules = params.rules;
6745
+ policy.ruleCount = policy.rules.length;
6746
+ }
6747
+ // Individual updates by rule_id
6748
+ if (Array.isArray(params?.rule_updates)) {
6749
+ for (const upd of params.rule_updates) {
6750
+ const rule = policy.rules.find(r => r.rule_id === upd.rule_id);
6751
+ if (!rule) continue;
6752
+ if (upd.text !== undefined) rule.text = upd.text;
6753
+ if (upd.category !== undefined) rule.category = upd.category;
6754
+ if (upd.severity !== undefined) rule.severity = upd.severity;
6755
+ if (upd.mode !== undefined) rule.mode = upd.mode;
6756
+ if (upd.hook_stage !== undefined) rule.hook_stage = upd.hook_stage;
6757
+ }
6758
+ policy.ruleCount = policy.rules.length;
6759
+ }
6760
+
6761
+ writeRules(data);
6762
+ emitRuleSync(data);
6763
+ return jsonRpcOk(id, { ok: true, policy_id: policy.id, rule_count: policy.ruleCount });
6764
+ } catch (err) {
6765
+ return jsonRpcError(id, -32603, (err as Error).message);
6766
+ }
5471
6767
  }
5472
- });
5473
6768
 
5474
- // cli/commands/install.ts
5475
- var install_exports = {};
5476
- __export(install_exports, {
5477
- installCommand: () => installCommand,
5478
- parseArgs: () => parseArgs
5479
- });
5480
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync10, readdirSync } from "fs";
5481
- import { homedir as homedir10 } from "os";
5482
- import { join as join11 } from "path";
5483
- import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
5484
- import { createInterface as createInterface3 } from "readline";
5485
- function sanitizeGatewayCandidate(raw) {
5486
- if (!raw) return void 0;
5487
- return /^https?:\/\//.test(raw) ? raw : void 0;
5488
- }
5489
- function parseArgs(argv) {
5490
- const opts = {};
5491
- for (const a of argv) {
5492
- if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
5493
- else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
5494
- else if (a === "--skip-auth") opts.skipAuth = true;
5495
- else if (a === "--no-mcp") opts.noMcp = true;
5496
- else if (a === "--force" || a === "-f") opts.force = true;
5497
- else if (a === "--link-repo") opts.linkRepo = true;
6769
+ if (method === 'dashboard.create_policy') {
6770
+ try {
6771
+ const data = readRules();
6772
+ const name = (params?.name as string) || 'New Rule Set';
6773
+ const rules: Rule[] = (params?.rules || []).map((r: any) => ({
6774
+ rule_id: r.rule_id || genId(),
6775
+ text: r.text || '',
6776
+ category: r.category || 'custom',
6777
+ severity: r.severity || 'medium',
6778
+ mode: r.mode || 'audit',
6779
+ hook_stage: r.hook_stage || 'both',
6780
+ scope: r.scope || 'user',
6781
+ }));
6782
+ const policy: Policy = {
6783
+ id: \`policy_\${Date.now()}_\${Math.random().toString(36).slice(2, 6)}\`,
6784
+ name,
6785
+ rules,
6786
+ ruleCount: rules.length,
6787
+ scopeOwner: 'user',
6788
+ isActive: true,
6789
+ };
6790
+ data.policies.push(policy);
6791
+ writeRules(data);
6792
+ emitRuleSync(data);
6793
+ return jsonRpcOk(id, { ok: true, policy_id: policy.id, name: policy.name });
6794
+ } catch (err) {
6795
+ return jsonRpcError(id, -32603, (err as Error).message);
6796
+ }
5498
6797
  }
5499
- if (!opts.gatewayUrl) {
5500
- const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
5501
- if (fromEnv) opts.gatewayUrl = fromEnv;
6798
+
6799
+ if (method === 'dashboard.delete_policy') {
6800
+ try {
6801
+ const data = readRules();
6802
+ const policyId = params?.policy_id as string | undefined;
6803
+ const idx = policyId ? data.policies.findIndex(p => p.id === policyId) : -1;
6804
+ if (idx === -1) return jsonRpcError(id, -32602, \`Policy not found: \${policyId}\`);
6805
+
6806
+ if (params?.hard === true) {
6807
+ data.policies.splice(idx, 1);
6808
+ } else {
6809
+ data.policies[idx].isActive = false;
6810
+ }
6811
+ writeRules(data);
6812
+ emitRuleSync(data);
6813
+ return jsonRpcOk(id, { ok: true, policy_id: policyId });
6814
+ } catch (err) {
6815
+ return jsonRpcError(id, -32603, (err as Error).message);
6816
+ }
5502
6817
  }
5503
- return opts;
6818
+
6819
+ if (method === 'dashboard.list_policies') {
6820
+ try {
6821
+ const data = readRules();
6822
+ return jsonRpcOk(id, {
6823
+ policies: data.policies.map(p => ({
6824
+ id: p.id,
6825
+ name: p.name,
6826
+ rules: p.rules,
6827
+ ruleCount: p.ruleCount,
6828
+ isActive: p.isActive,
6829
+ scopeOwner: p.scopeOwner,
6830
+ })),
6831
+ active_policy_id: data.config.activePolicyId,
6832
+ });
6833
+ } catch (err) {
6834
+ return jsonRpcError(id, -32603, (err as Error).message);
6835
+ }
6836
+ }
6837
+
6838
+ return jsonRpcError(id, -32601, \`Unknown method: \${method}\`);
5504
6839
  }
5505
- async function promptTranscriptConsent() {
5506
- const rl = createInterface3({ input: process.stdin, output: process.stdout });
5507
- return new Promise((resolve2) => {
5508
- rl.question(
5509
- "Would you like Synkro to use Claude Code session transcripts\nto generate guardrail rules and policies for your team? (Y/n) ",
5510
- (answer) => {
5511
- rl.close();
5512
- const trimmed = answer.trim().toLowerCase();
5513
- resolve2(trimmed === "" || trimmed === "y" || trimmed === "yes");
6840
+
6841
+ // \u2500\u2500\u2500 HTTP Server \u2500\u2500\u2500
6842
+
6843
+ const server = Bun.serve({
6844
+ port: PORT,
6845
+ async fetch(req) {
6846
+ const origin = req.headers.get('origin') || '';
6847
+ const allowedOrigin = /^https?:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$/.test(origin) ? origin : 'http://localhost:4322';
6848
+ const cors = { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' };
6849
+
6850
+ if (req.method === 'GET') {
6851
+ return Response.json({ name: 'synkro-guardrails-local', version: '1.0.0', status: 'ok' }, { headers: cors });
6852
+ }
6853
+
6854
+ if (req.method === 'POST') {
6855
+ const authHeader = req.headers.get('authorization') || '';
6856
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
6857
+ if (token !== SERVER_TOKEN) {
6858
+ return Response.json({ error: 'Unauthorized' }, { status: 401, headers: cors });
5514
6859
  }
5515
- );
5516
- });
5517
- }
5518
- function ensureSynkroDir() {
5519
- mkdirSync8(SYNKRO_DIR2, { recursive: true });
5520
- mkdirSync8(HOOKS_DIR, { recursive: true });
5521
- mkdirSync8(BIN_DIR, { recursive: true });
5522
- mkdirSync8(OFFSETS_DIR, { recursive: true });
5523
- }
5524
- function writeHookScripts() {
5525
- const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
5526
- const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
5527
- const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
5528
- const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
5529
- const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
5530
- const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
5531
- const agentJudgeScriptPath = join11(HOOKS_DIR, "cc-agent-judge.ts");
5532
- const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
5533
- const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
5534
- const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
5535
- const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
5536
- const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
5537
- const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
5538
- const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
5539
- const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
5540
- const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
5541
- const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
5542
- writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
5543
- writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
5544
- writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
5545
- writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
5546
- writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
5547
- writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
5548
- writeFileSync7(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
5549
- writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
5550
- writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
5551
- writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
5552
- writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
5553
- writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
5554
- writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
5555
- writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
5556
- writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
5557
- writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
5558
- writeFileSync7(cursorBashFollowupPath, CURSOR_BASH_FOLLOWUP_SCRIPT, "utf-8");
6860
+ try {
6861
+ const body = await req.json();
6862
+ const result = await handleRpc(body);
6863
+ if (result === null) return new Response('', { status: 204, headers: cors });
6864
+ return Response.json(result, { headers: cors });
6865
+ } catch (err) {
6866
+ return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });
6867
+ }
6868
+ }
6869
+
6870
+ if (req.method === 'OPTIONS') {
6871
+ return new Response('', { status: 204, headers: cors });
6872
+ }
6873
+
6874
+ return new Response('Method not allowed', { status: 405 });
6875
+ },
6876
+ });
6877
+
6878
+ console.log(\`[synkro] local MCP guardrails server listening on http://127.0.0.1:\${server.port}\`);
6879
+ `, "utf-8");
5559
6880
  chmodSync2(bashScriptPath, 493);
5560
6881
  chmodSync2(bashFollowupScriptPath, 493);
5561
6882
  chmodSync2(editPrecheckScriptPath, 493);
@@ -5573,6 +6894,8 @@ function writeHookScripts() {
5573
6894
  chmodSync2(cursorEditPrecheckPath, 493);
5574
6895
  chmodSync2(cursorEditCapturePath, 493);
5575
6896
  chmodSync2(cursorBashFollowupPath, 493);
6897
+ chmodSync2(cursorSessionStartPath, 493);
6898
+ chmodSync2(mcpLocalServerPath, 493);
5576
6899
  return {
5577
6900
  bashScript: bashScriptPath,
5578
6901
  bashFollowupScript: bashFollowupScriptPath,
@@ -5588,7 +6911,9 @@ function writeHookScripts() {
5588
6911
  cursorBashJudgeScript: cursorBashJudgePath,
5589
6912
  cursorEditPrecheckScript: cursorEditPrecheckPath,
5590
6913
  cursorEditCaptureScript: cursorEditCapturePath,
5591
- cursorBashFollowupScript: cursorBashFollowupPath
6914
+ cursorBashFollowupScript: cursorBashFollowupPath,
6915
+ cursorSessionStartScript: cursorSessionStartPath,
6916
+ mcpLocalServerScript: mcpLocalServerPath
5592
6917
  };
5593
6918
  }
5594
6919
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -5600,7 +6925,7 @@ function shellQuoteSingle(value) {
5600
6925
  }
5601
6926
  function resolveSynkroBundle() {
5602
6927
  const scriptPath = process.argv[1];
5603
- if (scriptPath && existsSync11(scriptPath)) return scriptPath;
6928
+ if (scriptPath && existsSync10(scriptPath)) return scriptPath;
5604
6929
  return null;
5605
6930
  }
5606
6931
  function writeConfigEnv(opts) {
@@ -5620,7 +6945,7 @@ function writeConfigEnv(opts) {
5620
6945
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5621
6946
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5622
6947
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5623
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.65")}`
6948
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.67")}`
5624
6949
  ];
5625
6950
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5626
6951
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -5635,7 +6960,7 @@ function writeConfigEnv(opts) {
5635
6960
  chmodSync2(CONFIG_PATH3, 384);
5636
6961
  }
5637
6962
  function updateLocalInferenceFlag(enabled) {
5638
- if (!existsSync11(CONFIG_PATH3)) return;
6963
+ if (!existsSync10(CONFIG_PATH3)) return;
5639
6964
  let content = readFileSync10(CONFIG_PATH3, "utf-8");
5640
6965
  const flag = enabled ? "yes" : "no";
5641
6966
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
@@ -5665,7 +6990,7 @@ function collectLocalMetadata() {
5665
6990
  meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
5666
6991
  } catch {
5667
6992
  }
5668
- const claudeDir = join11(homedir10(), ".claude");
6993
+ const claudeDir = join11(homedir11(), ".claude");
5669
6994
  try {
5670
6995
  const settings = JSON.parse(readFileSync10(join11(claudeDir, "settings.json"), "utf-8"));
5671
6996
  const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
@@ -5704,7 +7029,7 @@ async function fetchUserProfile(gatewayUrl, token) {
5704
7029
  const resp = await fetch(`${gatewayUrl}/api/v1/cli/me`, {
5705
7030
  headers: { "Authorization": `Bearer ${token}` }
5706
7031
  });
5707
- if (!resp.ok) return { tier: "pro", inference: "fast", localInference: false };
7032
+ if (!resp.ok) return { tier: "pro", inference: "fast", localInference: false, captureDepth: "full" };
5708
7033
  const data = await resp.json();
5709
7034
  const meta = collectLocalMetadata();
5710
7035
  fetch(`${gatewayUrl}/api/v1/cli/me`, {
@@ -5716,10 +7041,11 @@ async function fetchUserProfile(gatewayUrl, token) {
5716
7041
  return {
5717
7042
  tier: data.plan_tier ?? "pro",
5718
7043
  inference: data.fast_inference ? "fast" : "standard",
5719
- localInference: !!data.local_inference
7044
+ localInference: !!data.local_inference,
7045
+ captureDepth: data.capture_depth ?? "full"
5720
7046
  };
5721
7047
  } catch {
5722
- return { tier: "pro", inference: "fast", localInference: false };
7048
+ return { tier: "pro", inference: "fast", localInference: false, captureDepth: "full" };
5723
7049
  }
5724
7050
  }
5725
7051
  function assertGatewayAllowed(gatewayUrl) {
@@ -5754,10 +7080,10 @@ function isAlreadyInstalled() {
5754
7080
  join11(HOOKS_DIR, "cc-stop-summary.ts"),
5755
7081
  join11(HOOKS_DIR, "cc-session-start.ts")
5756
7082
  ];
5757
- if (!requiredScripts.every((p) => existsSync11(p))) return false;
5758
- if (!existsSync11(CONFIG_PATH3)) return false;
5759
- const settingsPath = join11(homedir10(), ".claude", "settings.json");
5760
- if (!existsSync11(settingsPath)) return false;
7083
+ if (!requiredScripts.every((p) => existsSync10(p))) return false;
7084
+ if (!existsSync10(CONFIG_PATH3)) return false;
7085
+ const settingsPath = join11(homedir11(), ".claude", "settings.json");
7086
+ if (!existsSync10(settingsPath)) return false;
5761
7087
  try {
5762
7088
  const settings = JSON.parse(readFileSync10(settingsPath, "utf-8"));
5763
7089
  const hooks = settings?.hooks;
@@ -5791,8 +7117,8 @@ function printChannelDiagnostics() {
5791
7117
  }
5792
7118
  }
5793
7119
  }
5794
- const logPath = join11(homedir10(), ".synkro", "cc_sessions", "run-claude.log");
5795
- if (existsSync11(logPath)) {
7120
+ const logPath = join11(homedir11(), ".synkro", "cc_sessions", "run-claude.log");
7121
+ if (existsSync10(logPath)) {
5796
7122
  const logContent = readFileSync10(logPath, "utf-8").trim().split("\n").slice(-10);
5797
7123
  console.warn(` run-claude.log:`);
5798
7124
  for (const line of logContent) console.warn(` ${line}`);
@@ -5801,6 +7127,101 @@ function printChannelDiagnostics() {
5801
7127
  }
5802
7128
  console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.`);
5803
7129
  }
7130
+ async function backfillLocalRules(gatewayUrl, token) {
7131
+ if (existsSync10(RULES_PATH)) {
7132
+ console.log(" Local rules already exist \u2014 skipping cloud backfill.");
7133
+ return;
7134
+ }
7135
+ try {
7136
+ const resp = await fetch(`${gatewayUrl}/api/v1/hook/config`, {
7137
+ headers: { "Authorization": `Bearer ${token}` },
7138
+ signal: AbortSignal.timeout(8e3)
7139
+ });
7140
+ if (!resp.ok) {
7141
+ console.log(" No cloud rules to backfill.");
7142
+ return;
7143
+ }
7144
+ const data = await resp.json();
7145
+ const rules = (data.rules || []).map((r) => ({
7146
+ rule_id: r.rule_id || `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
7147
+ text: r.text || "",
7148
+ category: r.category || "custom",
7149
+ severity: r.severity || "medium",
7150
+ mode: r.mode || "blocking",
7151
+ hook_stage: r.hook_stage || "both",
7152
+ scope: r.scope || "user"
7153
+ }));
7154
+ const policyName = data.active_policy_name || "My Rules";
7155
+ const policyId = data.active_policy_id || "local-policy";
7156
+ const scanExemptions = (data.scan_exemptions || []).filter((e) => e && typeof e.path === "string").map((e) => ({ path: e.path, cwe_id: e.cwe_id || "", reason: e.reason }));
7157
+ const silent = data.silent_mode === true || data.silent_mode === "true";
7158
+ const rulesFile = {
7159
+ policies: [{
7160
+ id: policyId,
7161
+ name: policyName,
7162
+ rules,
7163
+ ruleCount: rules.length,
7164
+ scopeOwner: "user",
7165
+ isActive: true
7166
+ }],
7167
+ config: { silent, activePolicyId: policyId },
7168
+ scanExemptions
7169
+ };
7170
+ const tmp = RULES_PATH + ".tmp";
7171
+ writeFileSync7(tmp, JSON.stringify(rulesFile, null, 2) + "\n", "utf-8");
7172
+ renameSync5(tmp, RULES_PATH);
7173
+ const telemetryPath = join11(SYNKRO_DIR2, "telemetry.jsonl");
7174
+ const event = {
7175
+ capture_type: "rule_sync",
7176
+ policy_id: policyId,
7177
+ policy_name: policyName,
7178
+ rules,
7179
+ rule_count: rules.length,
7180
+ scan_exemptions: scanExemptions,
7181
+ silent,
7182
+ _ts: (/* @__PURE__ */ new Date()).toISOString()
7183
+ };
7184
+ try {
7185
+ appendFileSync2(telemetryPath, JSON.stringify(event) + "\n", "utf-8");
7186
+ } catch {
7187
+ }
7188
+ console.log(` Backfilled ${rules.length} rules from cloud to ~/.synkro/rules.json`);
7189
+ } catch (err) {
7190
+ console.warn(` \u26A0 Cloud backfill failed: ${err.message}`);
7191
+ }
7192
+ }
7193
+ async function startLocalMcpServer() {
7194
+ const serverScript = join11(HOOKS_DIR, "mcp-local-server.ts");
7195
+ if (!existsSync10(serverScript)) {
7196
+ console.warn(" \u26A0 Local MCP server script not found \u2014 skipping.");
7197
+ return;
7198
+ }
7199
+ try {
7200
+ const probe = await fetch(`http://127.0.0.1:${MCP_LOCAL_PORT}/`, { signal: AbortSignal.timeout(1e3) });
7201
+ if (probe.ok) {
7202
+ console.log(` Local MCP server already running on port ${MCP_LOCAL_PORT}`);
7203
+ return;
7204
+ }
7205
+ } catch {
7206
+ }
7207
+ const proc = spawn2("bun", ["run", serverScript], {
7208
+ stdio: "ignore",
7209
+ detached: true
7210
+ });
7211
+ proc.unref();
7212
+ for (let i = 0; i < 25; i++) {
7213
+ await new Promise((r) => setTimeout(r, 200));
7214
+ try {
7215
+ const probe = await fetch(`http://127.0.0.1:${MCP_LOCAL_PORT}/`, { signal: AbortSignal.timeout(500) });
7216
+ if (probe.ok) {
7217
+ console.log(` Local MCP server started on port ${MCP_LOCAL_PORT}`);
7218
+ return;
7219
+ }
7220
+ } catch {
7221
+ }
7222
+ }
7223
+ console.warn(` \u26A0 Local MCP server did not start within 5s \u2014 it may need to be started manually.`);
7224
+ }
5804
7225
  async function installCommand(opts = {}) {
5805
7226
  const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
5806
7227
  try {
@@ -5977,39 +7398,13 @@ async function installCommand(opts = {}) {
5977
7398
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
5978
7399
  editPrecheckScriptPath: scripts.cursorEditPrecheckScript,
5979
7400
  editCaptureScriptPath: scripts.cursorEditCaptureScript,
5980
- bashFollowupScriptPath: scripts.cursorBashFollowupScript
7401
+ bashFollowupScriptPath: scripts.cursorBashFollowupScript,
7402
+ sessionStartScriptPath: scripts.cursorSessionStartScript
5981
7403
  });
5982
7404
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
5983
7405
  }
5984
7406
  }
5985
7407
  console.log();
5986
- if (hasClaudeCode && !opts.noMcp) {
5987
- try {
5988
- const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
5989
- method: "POST",
5990
- headers: {
5991
- "Authorization": `Bearer ${token}`,
5992
- "Content-Type": "application/json"
5993
- },
5994
- body: "{}"
5995
- });
5996
- if (!mintResp.ok) {
5997
- const errText = await mintResp.text().catch(() => "");
5998
- throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
5999
- }
6000
- const minted = await mintResp.json();
6001
- const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
6002
- console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
6003
- console.log(` url: ${mcp.url}`);
6004
- console.log(` expires: ${minted.expires_at} (~1 year)`);
6005
- console.log(" (restart any running Claude Code session for it to load)");
6006
- console.log();
6007
- } catch (err) {
6008
- console.warn(` \u26A0 MCP registration failed: ${err.message}`);
6009
- console.warn(" Hooks are still installed. Re-run `synkro-cli install` to retry MCP setup.");
6010
- console.log();
6011
- }
6012
- }
6013
7408
  let userId;
6014
7409
  let orgId;
6015
7410
  let email;
@@ -6021,6 +7416,50 @@ async function installCommand(opts = {}) {
6021
7416
  } catch {
6022
7417
  }
6023
7418
  const profile = await fetchUserProfile(gatewayUrl, token);
7419
+ const useLocalMcp = profile.captureDepth === "local_only" || profile.localInference;
7420
+ if (hasClaudeCode && !opts.noMcp) {
7421
+ if (useLocalMcp) {
7422
+ try {
7423
+ const mcp = installMcpConfig({ gatewayUrl, bearerToken: "", local: true });
7424
+ console.log(`Registered local MCP guardrails server in ${mcp.path}`);
7425
+ console.log(` url: ${mcp.url}`);
7426
+ console.log(" (rules stored in ~/.synkro/rules.json)");
7427
+ console.log();
7428
+ await backfillLocalRules(gatewayUrl, token);
7429
+ await startLocalMcpServer();
7430
+ } catch (err) {
7431
+ console.warn(` \u26A0 Local MCP setup failed: ${err.message}`);
7432
+ console.warn(" Hooks are still installed. Re-run `synkro-cli install` to retry.");
7433
+ console.log();
7434
+ }
7435
+ } else {
7436
+ try {
7437
+ const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
7438
+ method: "POST",
7439
+ headers: {
7440
+ "Authorization": `Bearer ${token}`,
7441
+ "Content-Type": "application/json"
7442
+ },
7443
+ body: "{}"
7444
+ });
7445
+ if (!mintResp.ok) {
7446
+ const errText = await mintResp.text().catch(() => "");
7447
+ throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
7448
+ }
7449
+ const minted = await mintResp.json();
7450
+ const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
7451
+ console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
7452
+ console.log(` url: ${mcp.url}`);
7453
+ console.log(` expires: ${minted.expires_at} (~1 year)`);
7454
+ console.log(" (restart any running Claude Code session for it to load)");
7455
+ console.log();
7456
+ } catch (err) {
7457
+ console.warn(` \u26A0 MCP registration failed: ${err.message}`);
7458
+ console.warn(" Hooks are still installed. Re-run `synkro-cli install` to retry MCP setup.");
7459
+ console.log();
7460
+ }
7461
+ }
7462
+ }
6024
7463
  const priorLocalFlag = (() => {
6025
7464
  try {
6026
7465
  const content = readFileSync10(CONFIG_PATH3, "utf-8");
@@ -6173,8 +7612,8 @@ function detectGitRepo2() {
6173
7612
  function getClaudeProjectsFolder() {
6174
7613
  const cwd = process.cwd();
6175
7614
  const sanitized = "-" + cwd.replace(/\//g, "-");
6176
- const projectsDir = join11(homedir10(), ".claude", "projects", sanitized);
6177
- return existsSync11(projectsDir) ? projectsDir : null;
7615
+ const projectsDir = join11(homedir11(), ".claude", "projects", sanitized);
7616
+ return existsSync10(projectsDir) ? projectsDir : null;
6178
7617
  }
6179
7618
  function extractSessionInsights(projectsDir) {
6180
7619
  const insights = [];
@@ -6350,7 +7789,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
6350
7789
  }
6351
7790
  return { sessions: totalSessions, messages: totalMessages };
6352
7791
  }
6353
- var SYNKRO_DIR2, HOOKS_DIR, BIN_DIR, CONFIG_PATH3, OFFSETS_DIR;
7792
+ var SYNKRO_DIR2, HOOKS_DIR, BIN_DIR, CONFIG_PATH3, OFFSETS_DIR, RULES_PATH, MCP_LOCAL_PORT;
6354
7793
  var init_install2 = __esm({
6355
7794
  "cli/commands/install.ts"() {
6356
7795
  "use strict";
@@ -6369,11 +7808,13 @@ var init_install2 = __esm({
6369
7808
  init_install();
6370
7809
  init_pueue();
6371
7810
  init_client();
6372
- SYNKRO_DIR2 = join11(homedir10(), ".synkro");
7811
+ SYNKRO_DIR2 = join11(homedir11(), ".synkro");
6373
7812
  HOOKS_DIR = join11(SYNKRO_DIR2, "hooks");
6374
7813
  BIN_DIR = join11(SYNKRO_DIR2, "bin");
6375
7814
  CONFIG_PATH3 = join11(SYNKRO_DIR2, "config.env");
6376
7815
  OFFSETS_DIR = join11(SYNKRO_DIR2, ".transcript-offsets");
7816
+ RULES_PATH = join11(SYNKRO_DIR2, "rules.json");
7817
+ MCP_LOCAL_PORT = 8931;
6377
7818
  }
6378
7819
  });
6379
7820
 
@@ -6449,11 +7890,11 @@ var status_exports = {};
6449
7890
  __export(status_exports, {
6450
7891
  statusCommand: () => statusCommand
6451
7892
  });
6452
- import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
6453
- import { homedir as homedir11 } from "os";
7893
+ import { existsSync as existsSync11, readFileSync as readFileSync11 } from "fs";
7894
+ import { homedir as homedir12 } from "os";
6454
7895
  import { join as join12 } from "path";
6455
7896
  function readConfigEnv() {
6456
- if (!existsSync12(CONFIG_PATH4)) return {};
7897
+ if (!existsSync11(CONFIG_PATH4)) return {};
6457
7898
  const out = {};
6458
7899
  const raw = readFileSync11(CONFIG_PATH4, "utf-8");
6459
7900
  for (const line of raw.split("\n")) {
@@ -6562,12 +8003,12 @@ async function statusCommand() {
6562
8003
  console.log("Hook scripts (Claude Code):");
6563
8004
  for (const f of ccHooks) {
6564
8005
  const p = join12(HOOKS_DIR2, f);
6565
- console.log(` ${existsSync12(p) ? "\u2713" : "\u2717"} ${p}`);
8006
+ console.log(` ${existsSync11(p) ? "\u2713" : "\u2717"} ${p}`);
6566
8007
  }
6567
8008
  console.log("Hook scripts (Cursor):");
6568
8009
  for (const f of cursorHooks) {
6569
8010
  const p = join12(HOOKS_DIR2, f);
6570
- console.log(` ${existsSync12(p) ? "\u2713" : "\u2717"} ${p}`);
8011
+ console.log(` ${existsSync11(p) ? "\u2713" : "\u2717"} ${p}`);
6571
8012
  }
6572
8013
  console.log();
6573
8014
  if (localInference) {
@@ -6610,7 +8051,7 @@ var init_status = __esm({
6610
8051
  init_cursorHookConfig();
6611
8052
  init_mcpConfig();
6612
8053
  init_pueue();
6613
- SYNKRO_DIR3 = join12(homedir11(), ".synkro");
8054
+ SYNKRO_DIR3 = join12(homedir12(), ".synkro");
6614
8055
  CONFIG_PATH4 = join12(SYNKRO_DIR3, "config.env");
6615
8056
  }
6616
8057
  });
@@ -6643,7 +8084,7 @@ __export(unlink_exports, {
6643
8084
  });
6644
8085
  import { createInterface as createInterface4 } from "readline";
6645
8086
  function ask2(rl, question) {
6646
- return new Promise((resolve2) => rl.question(question, resolve2));
8087
+ return new Promise((resolve3) => rl.question(question, resolve3));
6647
8088
  }
6648
8089
  async function unlinkCommand() {
6649
8090
  if (!isAuthenticated()) {
@@ -6700,11 +8141,11 @@ var config_exports = {};
6700
8141
  __export(config_exports, {
6701
8142
  configCommand: () => configCommand
6702
8143
  });
6703
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, existsSync as existsSync13 } from "fs";
8144
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, existsSync as existsSync12 } from "fs";
6704
8145
  import { join as join13 } from "path";
6705
- import { homedir as homedir12 } from "os";
8146
+ import { homedir as homedir13 } from "os";
6706
8147
  function readConfigEnv2() {
6707
- if (!existsSync13(CONFIG_PATH5)) return {};
8148
+ if (!existsSync12(CONFIG_PATH5)) return {};
6708
8149
  const out = {};
6709
8150
  for (const line of readFileSync12(CONFIG_PATH5, "utf-8").split("\n")) {
6710
8151
  const t = line.trim();
@@ -6715,7 +8156,7 @@ function readConfigEnv2() {
6715
8156
  return out;
6716
8157
  }
6717
8158
  function updateConfigValue(key, value) {
6718
- if (!existsSync13(CONFIG_PATH5)) {
8159
+ if (!existsSync12(CONFIG_PATH5)) {
6719
8160
  console.error("No config found. Run `synkro install` first.");
6720
8161
  process.exit(1);
6721
8162
  }
@@ -6786,7 +8227,7 @@ var init_config = __esm({
6786
8227
  "cli/commands/config.ts"() {
6787
8228
  "use strict";
6788
8229
  init_stub();
6789
- SYNKRO_DIR4 = join13(homedir12(), ".synkro");
8230
+ SYNKRO_DIR4 = join13(homedir13(), ".synkro");
6790
8231
  CONFIG_PATH5 = join13(SYNKRO_DIR4, "config.env");
6791
8232
  }
6792
8233
  });
@@ -6796,8 +8237,8 @@ var scanPr_exports = {};
6796
8237
  __export(scanPr_exports, {
6797
8238
  scanPrCommand: () => scanPrCommand
6798
8239
  });
6799
- import { execSync as execSync6, spawn as spawn2 } from "child_process";
6800
- import { readFileSync as readFileSync13, existsSync as existsSync14 } from "fs";
8240
+ import { execSync as execSync6, spawn as spawn3 } from "child_process";
8241
+ import { readFileSync as readFileSync13, existsSync as existsSync13 } from "fs";
6801
8242
  import { join as join14 } from "path";
6802
8243
  function parseMatchSpec(condition) {
6803
8244
  if (!condition.startsWith("match_spec:")) return null;
@@ -7005,9 +8446,9 @@ function spawnClaudeJudge(file, claudeToken, promptHeader) {
7005
8446
  Diff:
7006
8447
  ${hunks}`;
7007
8448
  const fullPrompt = promptHeader + userMessage;
7008
- return new Promise((resolve2) => {
8449
+ return new Promise((resolve3) => {
7009
8450
  const t0 = Date.now();
7010
- const proc = spawn2(
8451
+ const proc = spawn3(
7011
8452
  "claude",
7012
8453
  ["--print", "--model", "claude-sonnet-4-6", "--output-format", "json", "--no-session-persistence"],
7013
8454
  {
@@ -7033,7 +8474,7 @@ ${hunks}`;
7033
8474
  const latencyMs = Date.now() - t0;
7034
8475
  if (code !== 0) {
7035
8476
  console.warn(` claude exited ${code}: ${(stderr || stdout).slice(0, 500)}`);
7036
- resolve2({ findings: [], latencyMs });
8477
+ resolve3({ findings: [], latencyMs });
7037
8478
  return;
7038
8479
  }
7039
8480
  try {
@@ -7052,10 +8493,10 @@ ${hunks}`;
7052
8493
  description: f.description,
7053
8494
  fix: f.fix
7054
8495
  }));
7055
- resolve2({ findings, latencyMs });
8496
+ resolve3({ findings, latencyMs });
7056
8497
  } catch (parseErr) {
7057
8498
  console.warn(` failed to parse claude response: ${stdout.slice(0, 300)}`);
7058
- resolve2({ findings: [], latencyMs });
8499
+ resolve3({ findings: [], latencyMs });
7059
8500
  }
7060
8501
  });
7061
8502
  });
@@ -7104,9 +8545,9 @@ ${JSON.stringify(findings, null, 2)}
7104
8545
  `;
7105
8546
  }
7106
8547
  function spawnOpusConsolidator(findings, claudeToken) {
7107
- return new Promise((resolve2) => {
8548
+ return new Promise((resolve3) => {
7108
8549
  const prompt2 = buildConsolidationPrompt(findings);
7109
- const proc = spawn2(
8550
+ const proc = spawn3(
7110
8551
  "claude",
7111
8552
  ["--print", "--model", "claude-opus-4-7", "--output-format", "json", "--no-session-persistence"],
7112
8553
  {
@@ -7131,7 +8572,7 @@ function spawnOpusConsolidator(findings, claudeToken) {
7131
8572
  proc.on("close", (code) => {
7132
8573
  if (code !== 0) {
7133
8574
  console.warn(` opus consolidation exited ${code}: ${(stderr || stdout).slice(0, 300)}`);
7134
- resolve2(fallbackReview(findings));
8575
+ resolve3(fallbackReview(findings));
7135
8576
  return;
7136
8577
  }
7137
8578
  try {
@@ -7152,10 +8593,10 @@ function spawnOpusConsolidator(findings, claudeToken) {
7152
8593
  const order = ["low", "medium", "high", "critical"];
7153
8594
  return order.indexOf(f.severity) > order.indexOf(max) ? f.severity : max;
7154
8595
  }, "low");
7155
- resolve2({ summary: review.summary || "", comments, severity: maxSeverity });
8596
+ resolve3({ summary: review.summary || "", comments, severity: maxSeverity });
7156
8597
  } catch {
7157
8598
  console.warn(` failed to parse opus response, using fallback`);
7158
- resolve2(fallbackReview(findings));
8599
+ resolve3(fallbackReview(findings));
7159
8600
  }
7160
8601
  });
7161
8602
  });
@@ -7278,7 +8719,7 @@ function shouldFail(findings, threshold) {
7278
8719
  }
7279
8720
  function readRepoDeps() {
7280
8721
  const pkgPath = join14(process.cwd(), "package.json");
7281
- if (!existsSync14(pkgPath)) return {};
8722
+ if (!existsSync13(pkgPath)) return {};
7282
8723
  try {
7283
8724
  const pkg = JSON.parse(readFileSync13(pkgPath, "utf-8"));
7284
8725
  return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
@@ -7542,8 +8983,8 @@ var disconnect_exports = {};
7542
8983
  __export(disconnect_exports, {
7543
8984
  disconnectCommand: () => disconnectCommand
7544
8985
  });
7545
- import { existsSync as existsSync15, rmSync } from "fs";
7546
- import { homedir as homedir13 } from "os";
8986
+ import { existsSync as existsSync14, rmSync } from "fs";
8987
+ import { homedir as homedir14 } from "os";
7547
8988
  import { join as join15 } from "path";
7548
8989
  function tearDownLocalCC() {
7549
8990
  let hadTask = false;
@@ -7580,13 +9021,13 @@ function disconnectCommand(args2 = []) {
7580
9021
  console.log(`${mcpRemoved ? "\u2713" : "\xB7"} MCP guardrails server: ${mcpRemoved ? "removed entry from ~/.claude.json" : "no Synkro MCP entry found"}`);
7581
9022
  }
7582
9023
  if (purge) {
7583
- if (existsSync15(SYNKRO_DIR5)) {
9024
+ if (existsSync14(SYNKRO_DIR5)) {
7584
9025
  rmSync(SYNKRO_DIR5, { recursive: true, force: true });
7585
9026
  console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7586
9027
  } else {
7587
9028
  console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
7588
9029
  }
7589
- } else if (existsSync15(SYNKRO_DIR5)) {
9030
+ } else if (existsSync14(SYNKRO_DIR5)) {
7590
9031
  console.log(`Config preserved at ${SYNKRO_DIR5}. Run with --purge to remove.`);
7591
9032
  }
7592
9033
  console.log("\nSynkro disconnected.");
@@ -7601,7 +9042,7 @@ var init_disconnect = __esm({
7601
9042
  init_mcpConfig();
7602
9043
  init_pueue();
7603
9044
  init_install();
7604
- SYNKRO_DIR5 = join15(homedir13(), ".synkro");
9045
+ SYNKRO_DIR5 = join15(homedir14(), ".synkro");
7605
9046
  }
7606
9047
  });
7607
9048
 
@@ -7648,9 +9089,9 @@ __export(localCc_exports, {
7648
9089
  localCcCommand: () => localCcCommand
7649
9090
  });
7650
9091
  import { spawnSync as spawnSync4 } from "child_process";
7651
- import { homedir as homedir14 } from "os";
9092
+ import { homedir as homedir15 } from "os";
7652
9093
  import { join as join16 } from "path";
7653
- import { existsSync as existsSync16, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
9094
+ import { existsSync as existsSync15, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
7654
9095
  function printHelp() {
7655
9096
  console.log(`synkro local-cc \u2014 manage the local Claude Code inference session
7656
9097
 
@@ -7740,14 +9181,14 @@ TROUBLESHOOTING
7740
9181
  `);
7741
9182
  }
7742
9183
  function readGatewayUrl() {
7743
- if (existsSync16(CONFIG_PATH6)) {
9184
+ if (existsSync15(CONFIG_PATH6)) {
7744
9185
  const m = readFileSync14(CONFIG_PATH6, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
7745
9186
  if (m) return m[1];
7746
9187
  }
7747
9188
  return "https://api.synkro.sh";
7748
9189
  }
7749
9190
  function updateLocalInferenceFlag2(enabled) {
7750
- if (!existsSync16(CONFIG_PATH6)) return;
9191
+ if (!existsSync15(CONFIG_PATH6)) return;
7751
9192
  let content = readFileSync14(CONFIG_PATH6, "utf-8");
7752
9193
  const flag = enabled ? "yes" : "no";
7753
9194
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
@@ -7977,7 +9418,7 @@ function cmdLogs(rest) {
7977
9418
  if (!raw) console.log(" " + colorize("(use --raw / -r to see full payloads, --live / -f to follow)", 90));
7978
9419
  return;
7979
9420
  }
7980
- return new Promise((resolve2) => {
9421
+ return new Promise((resolve3) => {
7981
9422
  console.log(" " + colorize("\u2014 following new turns (Ctrl-C to exit) \u2014", 90));
7982
9423
  const stop = followTurns((t) => {
7983
9424
  console.log(" " + formatTurn(t, raw));
@@ -7985,7 +9426,7 @@ function cmdLogs(rest) {
7985
9426
  const onSigint = () => {
7986
9427
  stop();
7987
9428
  process.removeListener("SIGINT", onSigint);
7988
- resolve2();
9429
+ resolve3();
7989
9430
  };
7990
9431
  process.on("SIGINT", onSigint);
7991
9432
  });
@@ -8082,7 +9523,7 @@ var init_localCc = __esm({
8082
9523
  init_settings();
8083
9524
  init_client();
8084
9525
  init_stub();
8085
- CONFIG_PATH6 = join16(homedir14(), ".synkro", "config.env");
9526
+ CONFIG_PATH6 = join16(homedir15(), ".synkro", "config.env");
8086
9527
  }
8087
9528
  });
8088
9529
 
@@ -8092,10 +9533,10 @@ __export(grade_exports, {
8092
9533
  gradeCommand: () => gradeCommand
8093
9534
  });
8094
9535
  async function readStdin() {
8095
- return new Promise((resolve2, reject) => {
9536
+ return new Promise((resolve3, reject) => {
8096
9537
  const chunks = [];
8097
9538
  process.stdin.on("data", (c) => chunks.push(c));
8098
- process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
9539
+ process.stdin.on("end", () => resolve3(Buffer.concat(chunks).toString("utf-8")));
8099
9540
  process.stdin.on("error", reject);
8100
9541
  });
8101
9542
  }
@@ -8136,14 +9577,14 @@ var init_grade = __esm({
8136
9577
  });
8137
9578
 
8138
9579
  // cli/bootstrap.js
8139
- import { readFileSync as readFileSync15, existsSync as existsSync17 } from "fs";
8140
- import { resolve } from "path";
9580
+ import { readFileSync as readFileSync15, existsSync as existsSync16 } from "fs";
9581
+ import { resolve as resolve2 } from "path";
8141
9582
  var envCandidates = [
8142
- resolve(process.cwd(), ".env"),
8143
- resolve(process.env.HOME ?? "", ".synkro", "config.env")
9583
+ resolve2(process.cwd(), ".env"),
9584
+ resolve2(process.env.HOME ?? "", ".synkro", "config.env")
8144
9585
  ];
8145
9586
  for (const envPath of envCandidates) {
8146
- if (!existsSync17(envPath)) continue;
9587
+ if (!existsSync16(envPath)) continue;
8147
9588
  const envContent = readFileSync15(envPath, "utf-8");
8148
9589
  for (const line of envContent.split("\n")) {
8149
9590
  const trimmed = line.trim();