@synkro-sh/cli 1.4.45 → 1.4.47

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
@@ -145,51 +145,40 @@ function installCCHooks(settingsPath, config) {
145
145
  {
146
146
  type: "command",
147
147
  command: config.editPrecheckScriptPath,
148
- timeout: 15
148
+ timeout: 30
149
149
  }
150
150
  ],
151
151
  [SYNKRO_MARKER]: true
152
152
  });
153
153
  settings.hooks.PreToolUse.push({
154
- matcher: "ExitPlanMode",
155
- hooks: [
156
- {
157
- type: "command",
158
- command: config.planJudgeScriptPath,
159
- timeout: 45
160
- }
161
- ],
162
- [SYNKRO_MARKER]: true
163
- });
164
- settings.hooks.PostToolUse.push({
165
154
  matcher: "Edit|Write|MultiEdit|NotebookEdit",
166
155
  hooks: [
167
156
  {
168
157
  type: "command",
169
- command: config.editCaptureScriptPath,
170
- timeout: 20
158
+ command: config.cwePrecheckScriptPath,
159
+ timeout: 30
171
160
  }
172
161
  ],
173
162
  [SYNKRO_MARKER]: true
174
163
  });
175
- settings.hooks.PostToolUse.push({
164
+ settings.hooks.PreToolUse.push({
176
165
  matcher: "Edit|Write|MultiEdit|NotebookEdit",
177
166
  hooks: [
178
167
  {
179
168
  type: "command",
180
- command: config.cveScanScriptPath,
169
+ command: config.cvePrecheckScriptPath,
181
170
  timeout: 10
182
171
  }
183
172
  ],
184
173
  [SYNKRO_MARKER]: true
185
174
  });
186
- settings.hooks.PostToolUse.push({
187
- matcher: "Edit|Write|MultiEdit|NotebookEdit",
175
+ settings.hooks.PreToolUse.push({
176
+ matcher: "ExitPlanMode",
188
177
  hooks: [
189
178
  {
190
179
  type: "command",
191
- command: config.cweScanScriptPath,
192
- timeout: 15
180
+ command: config.planJudgeScriptPath,
181
+ timeout: 45
193
182
  }
194
183
  ],
195
184
  [SYNKRO_MARKER]: true
@@ -250,7 +239,7 @@ function uninstallCCHooks(settingsPath) {
250
239
  if (!existsSync2(settingsPath)) return false;
251
240
  const settings = readSettings(settingsPath);
252
241
  if (!settings.hooks) return false;
253
- const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop"];
242
+ const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop", "UserPromptSubmit"];
254
243
  for (const evt of events) {
255
244
  removeSynkroEntries(settings.hooks, evt);
256
245
  }
@@ -475,7 +464,7 @@ var init_mcpConfig = __esm({
475
464
  });
476
465
 
477
466
  // cli/installer/hookScripts.ts
478
- var SYNKRO_COMMON_SCRIPT, CC_BASH_JUDGE_SCRIPT, CC_EDIT_PRECHECK_SCRIPT, CC_EDIT_CAPTURE_SCRIPT, CC_PLAN_JUDGE_SCRIPT, CC_STOP_SUMMARY_SCRIPT, CC_SESSION_START_SCRIPT, CC_BASH_FOLLOWUP_SCRIPT, CC_CVE_SCAN_SCRIPT, CC_CWE_SCAN_SCRIPT, CC_TRANSCRIPT_SYNC_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT, CC_USER_PROMPT_SUBMIT_SCRIPT;
467
+ var SYNKRO_COMMON_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
479
468
  var init_hookScripts = __esm({
480
469
  "cli/installer/hookScripts.ts"() {
481
470
  "use strict";
@@ -499,6 +488,25 @@ synkro_load_jwt() {
499
488
  }
500
489
 
501
490
  synkro_refresh_jwt() {
491
+ # Lock via mkdir (atomic on all Unix including macOS \u2014 no flock needed)
492
+ local lockdir="\${CREDS_PATH}.lockdir"
493
+ if ! mkdir "$lockdir" 2>/dev/null; then
494
+ # Another hook is refreshing \u2014 wait and re-read
495
+ local _w=0
496
+ while [ -d "$lockdir" ] && [ $_w -lt 5 ]; do sleep 0.5; _w=$((_w+1)); done
497
+ JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
498
+ return 0
499
+ fi
500
+ trap "rmdir \\"$lockdir\\" 2>/dev/null" RETURN
501
+
502
+ # Re-check expiry \u2014 another hook may have just refreshed
503
+ local p2 exp2 now2
504
+ p2=$(printf '%s' "$JWT" | cut -d. -f2)
505
+ case $((\${#p2} % 4)) in 2) p2="\${p2}==";; 3) p2="\${p2}=";; esac
506
+ exp2=$(printf '%s' "$p2" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
507
+ now2=$(date -u +%s)
508
+ if [ $((exp2 - now2)) -ge 60 ]; then return 0; fi
509
+
502
510
  local rt
503
511
  rt=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
504
512
  if [ -z "$rt" ]; then return 1; fi
@@ -514,7 +522,12 @@ synkro_refresh_jwt() {
514
522
  new_rt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
515
523
  [ -z "$new_rt" ] && new_rt="$rt"
516
524
  local tmp="\${CREDS_PATH}.synkro.tmp"
517
- jq --arg at "$new_at" --arg rt "$new_rt" '. + {access_token:$at,refresh_token:$rt}' "$CREDS_PATH" > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
525
+ local existing
526
+ existing=$(cat "$CREDS_PATH" 2>/dev/null)
527
+ if [ -z "$existing" ] || ! echo "$existing" | jq -e '.' >/dev/null 2>&1; then
528
+ existing='{}'
529
+ fi
530
+ echo "$existing" | jq --arg at "$new_at" --arg rt "$new_rt" '. + {access_token:$at,refresh_token:$rt}' > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
518
531
  JWT="$new_at"
519
532
  }
520
533
 
@@ -542,10 +555,6 @@ synkro_channel_up() {
542
555
  (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
543
556
  }
544
557
 
545
- synkro_cwe_channel_up() {
546
- (exec 3<>/dev/tcp/127.0.0.1/8930) 2>/dev/null && exec 3<&- 3>&-
547
- }
548
-
549
558
  # Fetch hook config. Sets SYNKRO_CAPTURE_DEPTH, SYNKRO_TIER, SYNKRO_RULES, SYNKRO_SILENT, SYNKRO_POLICY_NAME.
550
559
  synkro_load_config() {
551
560
  local resp
@@ -558,8 +567,6 @@ synkro_load_config() {
558
567
  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 "[]")
559
568
  }
560
569
 
561
- # Build the tag prefix: [synkro:route:ruleset] or [synkro:silent]
562
- # Accepts optional $1 = route override; otherwise calls synkro_route().
563
570
  synkro_tag() {
564
571
  if [ "$SYNKRO_SILENT" = "true" ]; then echo "[synkro:silent]"; return; fi
565
572
  local route="\${1:-$(synkro_route)}"
@@ -567,166 +574,12 @@ synkro_tag() {
567
574
  echo "[synkro:\${route}:\${rs}]"
568
575
  }
569
576
 
570
- # Decide routing: "local" (grade on device) or "cloud" (POST to server)
571
577
  synkro_route() {
572
578
  [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
573
579
  synkro_channel_up && echo "local" && return
574
580
  echo "cloud"
575
581
  }
576
582
 
577
- # Routing for CWE channel (port 8930).
578
- synkro_cwe_route() {
579
- [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
580
- synkro_cwe_channel_up && echo "local" && return
581
- echo "cloud"
582
- }
583
-
584
- # Grade locally via synkro CLI channel. Reads prompt from stdin.
585
- synkro_local_grade() {
586
- local surface="$1"
587
- if ! synkro_channel_up; then
588
- echo "SYNKRO_CHANNEL_DOWN" >&2
589
- return 1
590
- fi
591
- if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
592
- node "$SYNKRO_CLI_BIN" grade "$surface" 2>/dev/null
593
- elif command -v synkro >/dev/null 2>&1; then
594
- synkro grade "$surface" 2>/dev/null
595
- else
596
- echo "SYNKRO_CLI_NOT_FOUND" >&2
597
- return 1
598
- fi
599
- }
600
-
601
- # Grade CWE locally via channel 2 (port 8930). Reads prompt from stdin.
602
- synkro_local_grade_cwe() {
603
- if ! synkro_cwe_channel_up; then
604
- echo "SYNKRO_CHANNEL_DOWN" >&2
605
- return 1
606
- fi
607
- if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
608
- SYNKRO_CHANNEL_PORT=8930 node "$SYNKRO_CLI_BIN" grade cwe 2>/dev/null
609
- elif command -v synkro >/dev/null 2>&1; then
610
- SYNKRO_CHANNEL_PORT=8930 synkro grade cwe 2>/dev/null
611
- else
612
- echo "SYNKRO_CLI_NOT_FOUND" >&2
613
- return 1
614
- fi
615
- }
616
-
617
- # Extract the CC model from the current turn's transcript. Call after reading PAYLOAD.
618
- # Sets CC_MODEL to the model used in the most recent assistant message.
619
- synkro_detect_cc_model() {
620
- CC_MODEL=""
621
- local tp
622
- tp=$(echo "\${1:-$PAYLOAD}" | jq -r '.transcript_path // empty' 2>/dev/null)
623
- [ -z "$tp" ] || [ ! -f "$tp" ] && return
624
- CC_MODEL=$(grep '"type":"assistant"' "$tp" 2>/dev/null | tail -1 | jq -r '.message.model // empty' 2>/dev/null)
625
- }
626
-
627
- # Parse <synkro-verdict>...</synkro-verdict> XML from local grader output.
628
- # Sets LOCAL_OK, LOCAL_REASON, LOCAL_RULE_ID, LOCAL_RULE_MODE, LOCAL_SEV, LOCAL_CAT.
629
- synkro_parse_local_verdict() {
630
- local resp="$1"
631
- LOCAL_OK="true"; LOCAL_REASON=""; LOCAL_RULE_ID=""; LOCAL_RULE_MODE=""; LOCAL_SEV="low"; LOCAL_CAT="clean"
632
- local inner
633
- inner=$(printf '%s' "$resp" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
634
- [ -z "$inner" ] && return
635
- local ok_tag
636
- ok_tag=$(printf '%s' "$inner" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
637
- [ -n "$ok_tag" ] && LOCAL_OK="$ok_tag"
638
- LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
639
- [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reasoning>(.*)</reasoning>.*|\\1|p' | head -1)
640
- if [ "$LOCAL_OK" = "false" ]; then
641
- LOCAL_RULE_ID=$(printf '%s' "$inner" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
642
- LOCAL_RULE_MODE=$(printf '%s' "$inner" | sed -nE 's|.*<rule_mode>(.*)</rule_mode>.*|\\1|p' | head -1)
643
- LOCAL_SEV=$(printf '%s' "$inner" | sed -nE 's|.*<risk_level>(.*)</risk_level>.*|\\1|p' | head -1)
644
- if [ -z "$LOCAL_RULE_ID" ]; then
645
- local fv
646
- fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
647
- LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
648
- [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
649
- [ -z "$LOCAL_SEV" ] && LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
650
- fi
651
- LOCAL_SEV="\${LOCAL_SEV:-high}"
652
- LOCAL_CAT=$(printf '%s' "$inner" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
653
- LOCAL_CAT="\${LOCAL_CAT:-uncategorized}"
654
- [ -z "$LOCAL_RULE_ID" ] && LOCAL_RULE_ID=$(printf '%s' "$LOCAL_REASON" | grep -oE '[Rr][0-9]{3}' | head -1)
655
- fi
656
- }
657
-
658
- # Fire anonymized telemetry for local verdicts. All args positional.
659
- synkro_capture_local() {
660
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
661
- local cc_model="\${CC_MODEL:-unknown}"
662
- (
663
- BODY=$(jq -n \\
664
- --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
665
- --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
666
- --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" --arg mdl "$cc_model" \\
667
- '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,cc_model:$mdl,model:$mdl,tool_name:$tn}
668
- + (if $r != "" then {repo:$r} else {} end)
669
- + (if $sid != "" then {session_id:$sid} else {} end)')
670
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
671
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
672
- -d "$BODY" --max-time 2 >/dev/null 2>&1
673
- ) &
674
- }
675
-
676
- # Fire full-content telemetry for local verdicts (used when capture_depth is full or evidence_on_violation).
677
- synkro_capture_local_full() {
678
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
679
- local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
680
- local cc_model="\${CC_MODEL:-unknown}"
681
- (
682
- BODY=$(jq -n \\
683
- --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
684
- --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
685
- --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" --arg mdl "$cc_model" \\
686
- --arg cmd "$command" --arg rsn "$reasoning" --arg cd "$SYNKRO_CAPTURE_DEPTH" \\
687
- --argjson rc "$rules_checked" --argjson vr "$violated_rules" --argjson rum "$recent_user_messages" \\
688
- '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,
689
- cc_model:$mdl,model:$mdl,tool_name:$tn,capture_depth:$cd,
690
- command:(if ($cmd|length) > 0 then $cmd else null end),
691
- reasoning:(if ($rsn|length) > 0 then $rsn else null end),
692
- rules_checked:$rc, violated_rules:$vr, recent_user_messages:$rum}
693
- + (if $r != "" then {repo:$r} else {} end)
694
- + (if $sid != "" then {session_id:$sid} else {} end)')
695
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
696
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
697
- -d "$BODY" --max-time 3 >/dev/null 2>&1
698
- ) &
699
- }
700
-
701
- # Dispatch local verdict capture based on capture_depth privacy setting.
702
- # For full: always send full content. For evidence_on_violation: full only on violations. For local_only: anonymized only.
703
- synkro_dispatch_capture() {
704
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
705
- local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
706
- local send_full=false
707
- case "\${SYNKRO_CAPTURE_DEPTH:-local_only}" in
708
- full) send_full=true ;;
709
- evidence_on_violation)
710
- case "$verdict" in block|warning|deny) send_full=true ;; esac ;;
711
- esac
712
- if [ "$send_full" = "true" ]; then
713
- synkro_capture_local_full "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id" \\
714
- "$command" "$reasoning" "$rules_checked" "$violated_rules" "$recent_user_messages"
715
- else
716
- synkro_capture_local "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id"
717
- fi
718
- }
719
-
720
- # Look up a rule's mode from cached rules. Returns "blocking" or "audit".
721
- synkro_rule_mode() {
722
- local rid="$1"
723
- [ -z "$rid" ] || [ -z "\${SYNKRO_RULES:-}" ] && echo "blocking" && return
724
- local m
725
- m=$(printf '%s' "$SYNKRO_RULES" | jq -r --arg r "$rid" '[.[] | select(.rule_id == $r) | .mode] | if any(. == "blocking") then "blocking" else .[0] // "blocking" end' 2>/dev/null)
726
- [ -z "$m" ] || [ "$m" = "null" ] && m="blocking"
727
- echo "$m"
728
- }
729
-
730
583
  SYNKRO_CONSENT_FILE="$HOME/.synkro/.local-consent"
731
584
 
732
585
  _TAB=$(printf '\\t')
@@ -750,25 +603,6 @@ synkro_consent_consume() {
750
603
  awk -v p="$pat" -v r="$rep" '{if($0==p)print r;else print}' "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
751
604
  }
752
605
 
753
- synkro_consent_has_consumed() {
754
- local sid="$1" hash="$2"
755
- grep -q "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" 2>/dev/null
756
- }
757
-
758
- synkro_consent_clear_consumed() {
759
- local sid="$1" hash="$2"
760
- [ ! -f "$SYNKRO_CONSENT_FILE" ] && return
761
- local tmp="\${SYNKRO_CONSENT_FILE}.tmp"
762
- grep -v "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
763
- }
764
-
765
- SYNKRO_LAST_PROMPT_FILE="$HOME/.synkro/.last-prompt"
766
-
767
- synkro_read_last_prompt() {
768
- [ -f "$SYNKRO_LAST_PROMPT_FILE" ] || return
769
- cat "$SYNKRO_LAST_PROMPT_FILE" 2>/dev/null
770
- }
771
-
772
606
  synkro_post_with_retry() {
773
607
  local url="$1" body="$2" timeout="\${3:-8}"
774
608
  local resp
@@ -787,7 +621,7 @@ synkro_post_with_retry() {
787
621
  echo "$resp"
788
622
  }
789
623
  `;
790
- CC_BASH_JUDGE_SCRIPT = `#!/bin/bash
624
+ CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
791
625
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
792
626
  . "$SCRIPT_DIR/_synkro-common.sh"
793
627
 
@@ -798,159 +632,52 @@ synkro_ensure_fresh_jwt
798
632
  PAYLOAD=$(cat)
799
633
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
800
634
 
801
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
802
- case "$TOOL_NAME" in Bash|Read|Grep|Glob) ;; *) echo '{}'; exit 0 ;; esac
635
+ COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
636
+ if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
803
637
 
804
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
805
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
806
638
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
639
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
807
640
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
808
- PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
809
- synkro_detect_cc_model
810
-
811
- # Translate tool calls to command string for logging
812
- case "$TOOL_NAME" in
813
- Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
814
- Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
815
- Grep) COMMAND="grep -r '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)' $(echo "$PAYLOAD" | jq -r '.tool_input.path // "."' 2>/dev/null)" ;;
816
- Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
817
- esac
818
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
819
641
 
820
642
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
821
643
  synkro_log "bashGuard checking: $CMD_SHORT"
822
644
 
823
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
824
- USER_INTENT=""
825
- RECENT_USER_MESSAGES="[]"
826
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
827
- RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user") | (.message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end) | select(. != null and . != "")] | .[-5:]' 2>/dev/null || echo "[]")
828
- USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
829
- fi
830
-
831
- # Headless detection
832
- IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
833
- case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
834
-
835
- LAST_PROMPT=$(synkro_read_last_prompt)
836
-
837
645
  synkro_load_config
838
- ROUTE=$(synkro_route)
839
- TAG=$(synkro_tag "$ROUTE")
840
-
841
646
  if [ "$SYNKRO_SILENT" = "true" ]; then
842
- jq -n --arg m "$TAG bashGuard \u2192 skipped (silent mode)" '{systemMessage: $m}'
843
- exit 0
844
- fi
845
-
846
- if [ "$ROUTE" = "local" ]; then
847
- # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
848
- GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
849
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
850
- printf 'Working directory: %s\\nRepo: %s\\nCommand: %s\\nUser intent (last human message): %s\\nLast user prompt: %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$COMMAND" "\${USER_INTENT:-none stated}" "\${LAST_PROMPT:-none}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
851
-
852
- CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" 2>&1)
853
- if [ $? -ne 0 ]; then
854
- jq -n --arg m "$TAG bashGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
855
- exit 0
856
- fi
857
- synkro_parse_local_verdict "$CC_RESP"
858
-
859
- # Build violated rules JSON for full-content capture
860
- VIOLATED_JSON="[]"
861
- [ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
862
-
863
- if [ "$LOCAL_OK" = "false" ]; then
864
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
865
- if [ "$RULE_MODE" = "audit" ]; then
866
- REASON="$TAG bashGuard \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
867
- jq -n --arg m "$REASON" '{systemMessage: $m}'
868
- synkro_dispatch_capture "bash" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
869
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
870
- else
871
- REASON="$TAG bashGuard \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
872
- jq -n --arg reason "$REASON" \\
873
- '{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
874
- synkro_dispatch_capture "bash" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
875
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
876
- fi
877
- else
878
- jq -n --arg m "$TAG bashGuard \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
879
- synkro_dispatch_capture "bash" "pass" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
880
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "\${RECENT_USER_MESSAGES:-[]}"
881
- fi
882
- exit 0
883
- fi
884
-
885
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
886
- CC_USAGE="{}"
887
- RECENT_MESSAGES="[]"
888
- RECENT_ACTIONS="[]"
889
- SESSION_SUMMARY=""
890
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
891
- _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
892
- if [ -n "$_LAST" ]; then
893
- CC_USAGE=$(echo "$_LAST" | jq -c '{input_tokens:.message.usage.input_tokens,output_tokens:.message.usage.output_tokens,cache_creation_input_tokens:.message.usage.cache_creation_input_tokens,cache_read_input_tokens:.message.usage.cache_read_input_tokens}' 2>/dev/null || echo "{}")
894
- fi
895
- RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user" or .type == "assistant") | {type, text: (.message.content | if type == "string" then .[0:500] else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}] | .[-10:]' 2>/dev/null || echo "[]")
896
- RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}] | .[-5:]' 2>/dev/null || echo "[]")
897
- SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
647
+ echo '{}'; exit 0
898
648
  fi
899
649
 
900
650
  BODY=$(jq -n \\
901
- --arg hook_event "PreToolUse" \\
902
- --arg tool_name "$TOOL_NAME" \\
903
- --argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
904
- --arg user_intent "$USER_INTENT" \\
905
- --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
906
- --argjson recent_messages "$RECENT_MESSAGES" \\
907
- --argjson recent_actions "$RECENT_ACTIONS" \\
651
+ --arg cmd "$COMMAND" \\
908
652
  --arg session_id "$SESSION_ID" \\
909
- --arg tool_use_id "$TOOL_USE_ID" \\
910
653
  --arg cwd "$CWD" \\
911
654
  --arg repo "$GIT_REPO" \\
912
- --arg permission_mode "$PERMISSION_MODE" \\
913
- --arg cc_model "$CC_MODEL" \\
914
- --argjson cc_usage "$CC_USAGE" \\
915
- --arg session_summary "$SESSION_SUMMARY" \\
916
- --arg last_prompt "\${LAST_PROMPT:-}" \\
917
655
  '{
918
- hook_event: $hook_event,
919
- tool_name: $tool_name,
920
- tool_input: $tool_input,
921
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
922
- last_user_message: (if ($last_prompt | length) > 0 then $last_prompt else null end),
923
- recent_user_messages: $recent_user_messages,
924
- recent_messages: $recent_messages,
925
- recent_actions: $recent_actions,
656
+ hook_event: "PreToolUse",
657
+ tool_name: "Bash",
658
+ tool_input: {command: $cmd},
659
+ response_format: "cursor",
926
660
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
927
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
928
661
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
929
- repo: (if ($repo | length) > 0 then $repo else null end),
930
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
931
- cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
932
- cc_usage: $cc_usage,
933
- session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
662
+ repo: (if ($repo | length) > 0 then $repo else null end)
934
663
  }')
935
664
 
936
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
665
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
937
666
 
938
667
  if [ -z "$RESP" ]; then
939
668
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
940
- echo '{}'
941
- exit 0
669
+ echo '{}'; exit 0
942
670
  fi
943
671
 
944
- if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
945
- synkro_log "bashGuard $CMD_SHORT \u2192 pass (no hook_response)"
672
+ # Server returns cursor-format directly in hook_response
673
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
674
+ echo "$RESP" | jq -c '.hook_response'
675
+ else
946
676
  echo '{}'
947
- exit 0
948
677
  fi
949
-
950
- echo "$RESP" | jq -c '.hook_response'
951
678
  exit 0
952
679
  `;
953
- CC_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
680
+ CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
954
681
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
955
682
  . "$SCRIPT_DIR/_synkro-common.sh"
956
683
 
@@ -962,245 +689,79 @@ PAYLOAD=$(cat)
962
689
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
963
690
 
964
691
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
965
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
966
-
967
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
968
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
969
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
970
692
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
693
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
971
694
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
972
- PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
973
- synkro_detect_cc_model
974
695
 
975
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
696
+ FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
697
+ CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
976
698
  if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
977
699
 
978
- FILE_SHORT=$(basename "$FILE_PATH")
979
- synkro_log "editGuard checking: $FILE_SHORT"
980
-
981
- IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
982
- case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
983
-
984
- # Read file before edit for reconstruction
985
- FILE_BEFORE=""
986
- if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
987
- FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
988
- fi
989
-
990
- # Reconstruct proposed content
991
- case "$TOOL_NAME" in
992
- Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
993
- Edit|MultiEdit)
994
- if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
995
- PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
996
- import os, json, sys
997
- fb = os.environ.get("FILE_BEFORE_LITERAL", "")
998
- ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
999
- result = fb
1000
- if "old_string" in ti and "new_string" in ti:
1001
- if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
1002
- elif "edits" in ti and isinstance(ti["edits"], list):
1003
- for e in ti["edits"]:
1004
- old = e.get("old_string", "") if isinstance(e, dict) else ""
1005
- new = e.get("new_string", "") if isinstance(e, dict) else ""
1006
- if old: result = result.replace(old, new, 1)
1007
- sys.stdout.write(result)
1008
- ' 2>/dev/null)
1009
- fi
1010
- if [ -z "$PROPOSED" ]; then
1011
- if [ "$TOOL_NAME" = "MultiEdit" ]; then
1012
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
1013
- else
1014
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
1015
- fi
1016
- fi ;;
1017
- NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
1018
- esac
1019
- if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
1020
-
1021
- DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null)
1022
- [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
1023
-
1024
- # Extract user intent from transcript
1025
- USER_INTENT=""
1026
- RECENT_USER_MESSAGES="[]"
1027
- RECENT_MESSAGES="[]"
1028
- RECENT_ACTIONS="[]"
1029
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1030
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1031
- RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user") | (.message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end) | select(. != null and . != "")] | .[-5:]' 2>/dev/null || echo "[]")
1032
- USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
1033
- RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user" or .type == "assistant") | {type, text: (.message.content | if type == "string" then .[0:500] else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}] | .[-10:]' 2>/dev/null || echo "[]")
1034
- RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}] | .[-5:]' 2>/dev/null || echo "[]")
1035
- fi
1036
-
1037
- LAST_PROMPT=$(synkro_read_last_prompt)
700
+ BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
701
+ synkro_log "editGuard checking: $BASENAME"
1038
702
 
1039
703
  synkro_load_config
1040
- ROUTE=$(synkro_route)
1041
- TAG=$(synkro_tag "$ROUTE")
1042
-
1043
704
  if [ "$SYNKRO_SILENT" = "true" ]; then
1044
- jq -n --arg m "$TAG editGuard \u2192 skipped (silent mode)" '{systemMessage: $m}'
1045
- exit 0
1046
- fi
1047
-
1048
- if [ "$ROUTE" = "local" ]; then
1049
- # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
1050
- GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
1051
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1052
- printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nProposed content (first 4000 chars):\\n%s\\nUser intent (last human message): %s\\nLast user prompt: %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$PROPOSED" | head -c 4000)" "\${USER_INTENT:-none stated}" "\${LAST_PROMPT:-none}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
1053
-
1054
- CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
1055
- if [ $? -ne 0 ]; then
1056
- jq -n --arg m "$TAG editGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
1057
- exit 0
1058
- fi
1059
- synkro_parse_local_verdict "$CC_RESP"
1060
-
1061
- # Build edit content description and violated rules for full-content capture
1062
- EDIT_CONTENT="file=$FILE_PATH content=$(printf '%s' "$PROPOSED" | head -c 2000)"
1063
- VIOLATED_JSON="[]"
1064
- [ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1065
-
1066
- if [ "$LOCAL_OK" = "false" ]; then
1067
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
1068
- if [ "$RULE_MODE" = "audit" ]; then
1069
- REASON="$TAG editGuard $FILE_SHORT \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
1070
- jq -n --arg m "$REASON" '{systemMessage: $m}'
1071
- synkro_dispatch_capture "edit" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1072
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
1073
- else
1074
- REASON="$TAG editGuard $FILE_SHORT \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
1075
- jq -n --arg reason "$REASON" \\
1076
- '{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
1077
- synkro_dispatch_capture "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1078
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
1079
- fi
1080
- else
1081
- jq -n --arg m "$TAG editGuard $FILE_SHORT \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
1082
- synkro_dispatch_capture "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1083
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
1084
- fi
1085
- exit 0
705
+ echo '{}'; exit 0
1086
706
  fi
1087
707
 
1088
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1089
708
  BODY=$(jq -n \\
1090
- --arg hook_event "PreToolUse" \\
1091
- --arg tool_name "$TOOL_NAME" \\
1092
- --argjson tool_input "$TOOL_INPUT" \\
1093
709
  --arg file_path "$FILE_PATH" \\
1094
- --arg content "$PROPOSED" \\
1095
- --arg file_before "$FILE_BEFORE" \\
1096
- --argjson diff "$DIFF_FIELD" \\
1097
- --arg user_intent "$USER_INTENT" \\
1098
- --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
1099
- --argjson recent_messages "$RECENT_MESSAGES" \\
1100
- --argjson recent_actions "$RECENT_ACTIONS" \\
710
+ --arg content "$CONTENT" \\
1101
711
  --arg session_id "$SESSION_ID" \\
1102
- --arg tool_use_id "$TOOL_USE_ID" \\
1103
712
  --arg cwd "$CWD" \\
1104
713
  --arg repo "$GIT_REPO" \\
1105
- --arg permission_mode "$PERMISSION_MODE" \\
1106
- --arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
1107
- --arg last_prompt "\${LAST_PROMPT:-}" \\
1108
714
  '{
1109
- hook_event: $hook_event,
1110
- tool_name: $tool_name,
1111
- tool_input: $tool_input,
715
+ hook_event: "PreToolUse",
716
+ tool_name: "Edit",
717
+ tool_input: {file_path: $file_path, content: $content},
1112
718
  file_path: $file_path,
1113
719
  content: $content,
1114
- file_before: (if ($file_before | length) > 0 then $file_before else null end),
1115
- diff: $diff,
1116
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
1117
- last_user_message: (if ($last_prompt | length) > 0 then $last_prompt else null end),
1118
- recent_user_messages: $recent_user_messages,
1119
- recent_messages: $recent_messages,
1120
- recent_actions: $recent_actions,
720
+ response_format: "cursor",
1121
721
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1122
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1123
722
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
1124
- repo: (if ($repo | length) > 0 then $repo else null end),
1125
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
1126
- headless: ($headless_flag == "1")
723
+ repo: (if ($repo | length) > 0 then $repo else null end)
1127
724
  }')
1128
725
 
1129
726
  RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
1130
727
 
1131
728
  if [ -z "$RESP" ]; then
1132
- synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
1133
- echo '{}'
1134
- exit 0
1135
- fi
1136
-
1137
- if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1138
- synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
1139
- echo '{}'
1140
- exit 0
729
+ synkro_log "editGuard $BASENAME \u2192 error (timeout)"
730
+ echo '{}'; exit 0
1141
731
  fi
1142
732
 
1143
- DECISION=$(echo "$RESP" | jq -r '.hook_response.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
1144
- if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
1145
- synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
733
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1146
734
  echo "$RESP" | jq -c '.hook_response'
1147
735
  else
1148
- REASON=$(echo "$RESP" | jq -r '.hook_response.reason // empty' 2>/dev/null)
1149
- if [ -n "$REASON" ]; then
1150
- synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
1151
- else
1152
- synkro_log "editGuard $FILE_SHORT \u2192 pass"
1153
- fi
1154
- echo "$RESP" | jq -c '.hook_response // {}'
736
+ echo '{}'
1155
737
  fi
1156
738
  exit 0
1157
739
  `;
1158
- CC_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
740
+ CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
1159
741
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1160
742
  . "$SCRIPT_DIR/_synkro-common.sh"
1161
743
 
1162
744
  JWT=$(synkro_load_jwt)
1163
745
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1164
- synkro_ensure_fresh_jwt
1165
746
 
1166
747
  PAYLOAD=$(cat)
1167
748
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1168
749
 
1169
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1170
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
750
+ FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
751
+ if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1171
752
 
1172
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1173
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1174
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1175
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
753
+ CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
754
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1176
755
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1177
- synkro_detect_cc_model
1178
-
1179
- # Correction followup (backgrounded)
1180
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
1181
- (
1182
- BODY=$(jq -n --arg tid "$TOOL_USE_ID" --arg sid "$SESSION_ID" '{capture_type:"correction_followup",tool_use_id:$tid,session_id:$sid,decision:"allow"}')
1183
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1184
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1185
- -d "$BODY" --max-time 2 >/dev/null 2>&1
1186
- ) &
1187
- fi
1188
-
1189
- # Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
1190
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1191
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1192
-
1193
- BASENAME=$(basename "$FILE_PATH")
1194
- synkro_log "editScan: $BASENAME"
1195
-
1196
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1197
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
756
+ BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
1198
757
 
1199
- DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null || echo "null")
1200
- [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
758
+ FULL_PATH="$FILE_PATH"
759
+ [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
760
+ FULL_CONTENT=""
761
+ [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
1201
762
 
1202
763
  DEPS_JSON="{}"
1203
- _PKG_DIR=$(dirname "$FILE_PATH")
764
+ _PKG_DIR="\${CWD:-.}"
1204
765
  while [ "$_PKG_DIR" != "/" ]; do
1205
766
  if [ -f "$_PKG_DIR/package.json" ]; then
1206
767
  DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
@@ -1209,907 +770,1961 @@ while [ "$_PKG_DIR" != "/" ]; do
1209
770
  _PKG_DIR=$(dirname "$_PKG_DIR")
1210
771
  done
1211
772
 
1212
- synkro_load_config
1213
- ROUTE=$(synkro_route)
1214
- TAG=$(synkro_tag "$ROUTE")
1215
-
1216
- if [ "$SYNKRO_SILENT" = "true" ]; then
1217
- echo '{}'; exit 0
1218
- fi
1219
-
1220
- if [ "$ROUTE" = "local" ]; then
1221
- # \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
1222
- GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
1223
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1224
- printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nContent (first 4000 chars):\\n%s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
1225
-
1226
- CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
1227
- if [ $? -ne 0 ]; then
1228
- echo '{}'; exit 0
1229
- fi
1230
- synkro_parse_local_verdict "$CC_RESP"
1231
-
1232
- SCAN_CONTENT="file=$FILE_PATH"
1233
- SCAN_VIOLATED="[]"
1234
- [ -n "$LOCAL_RULE_ID" ] && SCAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1235
-
1236
- if [ "$LOCAL_OK" = "false" ]; then
1237
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
1238
- if [ "$RULE_MODE" = "audit" ]; then
1239
- REASON="$TAG editScan $BASENAME \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
1240
- jq -n --arg m "$REASON" '{systemMessage: $m}'
1241
- synkro_dispatch_capture "edit_scan" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1242
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
1243
- else
1244
- REASON="$TAG editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
1245
- jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
1246
- synkro_dispatch_capture "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1247
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
1248
- fi
1249
- else
1250
- jq -n --arg m "$TAG editScan $BASENAME \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
1251
- synkro_dispatch_capture "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1252
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
1253
- fi
1254
- exit 0
1255
- fi
1256
-
1257
- # \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
1258
- BODY=$(jq -n \\
1259
- --arg hook_event "PostToolUse" \\
1260
- --arg tool_name "$TOOL_NAME" \\
1261
- --argjson tool_input "$TOOL_INPUT" \\
1262
- --arg file_path "$FILE_PATH" \\
1263
- --arg content "$FILE_CONTENT" \\
1264
- --argjson diff "$DIFF_FIELD" \\
1265
- --argjson dependencies "$DEPS_JSON" \\
1266
- --arg session_id "$SESSION_ID" \\
1267
- --arg tool_use_id "$TOOL_USE_ID" \\
1268
- --arg cwd "$CWD" \\
1269
- --arg repo "$GIT_REPO" \\
1270
- '{
1271
- hook_event: $hook_event,
1272
- tool_name: $tool_name,
1273
- tool_input: $tool_input,
1274
- file_path: $file_path,
1275
- content: $content,
1276
- diff: $diff,
1277
- dependencies: $dependencies,
1278
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1279
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1280
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1281
- repo: (if ($repo | length) > 0 then $repo else null end)
1282
- }')
1283
-
1284
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
773
+ synkro_log "editScan $BASENAME"
1285
774
 
1286
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1287
- synkro_log "editScan $BASENAME \u2192 error (no response)"
1288
- echo '{}'
1289
- exit 0
1290
- fi
775
+ (
776
+ BODY=$(jq -n \\
777
+ --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
778
+ --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
779
+ --argjson deps "$DEPS_JSON" \\
780
+ '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
781
+ + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
782
+ + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
783
+ + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
784
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
785
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
786
+ -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
787
+ ) &
788
+ disown 2>/dev/null || true
1291
789
 
1292
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1293
- echo "$RESP" | jq -c '.hook_response'
1294
- else
1295
- echo '{}'
1296
- fi
790
+ echo '{}'
1297
791
  exit 0
1298
792
  `;
1299
- CC_PLAN_JUDGE_SCRIPT = `#!/bin/bash
793
+ CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1300
794
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1301
795
  . "$SCRIPT_DIR/_synkro-common.sh"
1302
796
 
1303
797
  JWT=$(synkro_load_jwt)
1304
798
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1305
- synkro_ensure_fresh_jwt
1306
799
 
1307
800
  PAYLOAD=$(cat)
1308
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1309
-
1310
801
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1311
- if [ "$TOOL_NAME" != "ExitPlanMode" ]; then echo '{}'; exit 0; fi
1312
-
1313
- # ExitPlanMode's tool_input contains {allowedPrompts:[...]}, not plan content.
1314
- # Read from the most recently modified plan file instead.
1315
- PLANS_DIR="$HOME/.claude/plans"
1316
- PLAN_FILE=$(ls -t "$PLANS_DIR"/*.md 2>/dev/null | head -1)
1317
- if [ -z "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ]; then echo '{}'; exit 0; fi
1318
- PLAN=$(cat "$PLAN_FILE" 2>/dev/null)
1319
- if [ -z "$PLAN" ] || [ \${#PLAN} -lt 20 ]; then echo '{}'; exit 0; fi
1320
-
1321
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1322
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1323
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1324
- synkro_detect_cc_model
1325
-
1326
- PLAN_SHORT=$(printf '%s' "$PLAN" | head -c 80)
1327
- synkro_log "planReview checking: $PLAN_SHORT..."
1328
-
1329
- # Write review verdict into the plan file so it survives ExitPlanMode rejection
1330
- append_review_to_plan() {
1331
- local verdict="$1"
1332
- local tmp="\${PLAN_FILE}.synkro.tmp"
1333
- sed '/^<!-- synkro-plan-review -->$/,/^<!-- \\/synkro-plan-review -->$/d' "$PLAN_FILE" | sed -e :a -e '/^\\n*$/{$d;N;ba' -e '}' > "$tmp" 2>/dev/null
1334
- printf '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \u2014 %s\\n\\n%s\\n\\n<!-- /synkro-plan-review -->\\n' "$(date '+%Y-%m-%d %H:%M')" "$verdict" >> "$tmp"
1335
- mv "$tmp" "$PLAN_FILE" 2>/dev/null
1336
- }
802
+ case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
1337
803
 
1338
- synkro_load_config
1339
- ROUTE=$(synkro_route)
1340
- TAG=$(synkro_tag "$ROUTE")
804
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
805
+ TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1341
806
 
1342
- if [ "$SYNKRO_SILENT" = "true" ]; then
1343
- jq -n --arg m "$TAG planReview \u2192 skipped (silent mode)" '{systemMessage: $m}'
1344
- exit 0
807
+ IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
808
+ CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
809
+ CMD_HASH=""
810
+ if [ -n "$CMD" ]; then
811
+ CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
1345
812
  fi
1346
813
 
1347
- if [ "$ROUTE" = "local" ]; then
1348
- GRADER_FILE=$(mktemp -t synkro-plan.XXXXXX)
1349
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1350
- printf 'Working directory: %s\\nRepo: %s\\nPlan:\\n%s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$(printf '%s' "$PLAN" | head -c 8000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
1351
-
1352
- CC_RESP=$(synkro_local_grade plan < "$GRADER_FILE" 2>&1)
1353
- if [ $? -ne 0 ]; then
1354
- echo '{}'; exit 0
1355
- fi
1356
- synkro_parse_local_verdict "$CC_RESP"
1357
-
1358
- PLAN_CONTENT=$(printf '%s' "$PLAN" | head -c 2000)
1359
- PLAN_VIOLATED="[]"
1360
- [ -n "$LOCAL_RULE_ID" ] && PLAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1361
-
1362
- if [ "$LOCAL_OK" = "false" ]; then
1363
- VCOUNT=$(printf '%s' "$CC_RESP" | grep -c '<violation>' 2>/dev/null || echo "0")
1364
- [ "$VCOUNT" = "0" ] && VCOUNT="1"
1365
- REVIEW_MSG="\${VCOUNT} rule(s) relevant\${LOCAL_RULE_ID:+ (first: $LOCAL_RULE_ID)}: \${LOCAL_REASON:-check org rules during implementation}"
1366
- append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REVIEW_MSG"
1367
- MSG="$TAG planReview \u2192 $REVIEW_MSG"
1368
- jq -n --arg m "$MSG" '{systemMessage: $m}'
1369
- synkro_dispatch_capture "plan_review" "advisory" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
1370
- "$PLAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$PLAN_VIOLATED" "[]"
814
+ if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
815
+ if [ "$IS_ERROR" = "false" ]; then
816
+ synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
1371
817
  else
1372
- REVIEW_MSG="Clean \u2014 \${LOCAL_REASON:-no relevant org rules for this plan}"
1373
- append_review_to_plan "\u2705 $REVIEW_MSG"
1374
- jq -n --arg m "$TAG planReview \u2192 clean: \${LOCAL_REASON:-no relevant org rules for this plan}" '{systemMessage: $m}'
1375
- synkro_dispatch_capture "plan_review" "clean" "audit" "\${LOCAL_CAT:-general}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
1376
- "$PLAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
818
+ if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
819
+ synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
820
+ fi
1377
821
  fi
1378
- exit 0
1379
822
  fi
1380
823
 
1381
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1382
- BODY=$(jq -n \\
1383
- --arg hook_event "PreToolUse" \\
1384
- --arg tool_name "ExitPlanMode" \\
1385
- --arg plan "$(printf '%s' "$PLAN" | head -c 16000)" \\
1386
- --arg session_id "$SESSION_ID" \\
1387
- --arg cwd "$CWD" \\
1388
- --arg repo "$GIT_REPO" \\
1389
- '{
1390
- hook_event: $hook_event,
1391
- tool_name: $tool_name,
1392
- tool_input: {plan: $plan},
1393
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1394
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1395
- repo: (if ($repo | length) > 0 then $repo else null end)
1396
- }')
1397
-
1398
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
1399
-
1400
- if [ -z "$RESP" ]; then
1401
- synkro_log "planReview \u2192 error (timeout)"
1402
- echo '{}'
1403
- exit 0
1404
- fi
1405
-
1406
- if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1407
- echo '{}'
1408
- exit 0
824
+ if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
825
+ (
826
+ BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
827
+ --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
828
+ '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
829
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
830
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
831
+ -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
832
+ ) &
833
+ disown 2>/dev/null || true
1409
834
  fi
1410
835
 
1411
- # Force advisory: convert any blocking decision to systemMessage
1412
- HR=$(echo "$RESP" | jq -c '.hook_response')
1413
- if echo "$HR" | jq -e '.hookSpecificOutput.permissionDecision' >/dev/null 2>&1; then
1414
- REASON=$(echo "$HR" | jq -r '.hookSpecificOutput.permissionDecisionReason // "check org rules"' 2>/dev/null)
1415
- append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REASON"
1416
- jq -n --arg m "$TAG planReview \u2192 advisory: $REASON" '{systemMessage: $m}'
1417
- else
1418
- CLOUD_MSG=$(echo "$HR" | jq -r '.systemMessage // empty' 2>/dev/null)
1419
- [ -n "$CLOUD_MSG" ] && append_review_to_plan "\u2705 $CLOUD_MSG"
1420
- echo "$HR"
1421
- fi
836
+ echo '{}'
1422
837
  exit 0
1423
838
  `;
1424
- CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
1425
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1426
- . "$SCRIPT_DIR/_synkro-common.sh"
839
+ }
840
+ });
1427
841
 
1428
- JWT=$(synkro_load_jwt)
1429
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
842
+ // cli/installer/hookScriptsTs.ts
843
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, BASH_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS;
844
+ var init_hookScriptsTs = __esm({
845
+ "cli/installer/hookScriptsTs.ts"() {
846
+ "use strict";
847
+ SYNKRO_COMMON_TS = `
848
+ // Shared Synkro hook utilities \u2014 imported by all hook scripts.
849
+ import { readFileSync, writeFileSync, mkdirSync, rmdirSync, existsSync, renameSync } from 'node:fs';
850
+ import { join, dirname, basename, extname } from 'node:path';
851
+ import { homedir } from 'node:os';
852
+ import { execSync, spawn } from 'node:child_process';
1430
853
 
1431
- PAYLOAD=$(cat)
1432
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1433
- if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
854
+ // \u2500\u2500\u2500 Config \u2500\u2500\u2500
1434
855
 
1435
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1436
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1437
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
856
+ const HOME = homedir();
857
+ const CONFIG_PATH = join(HOME, '.synkro', 'config.env');
1438
858
 
1439
- # Aggregate token usage across all assistant turns in the transcript and POST silently
1440
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1441
- (
1442
- _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1443
- CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
1444
- TOTALS=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null \\
1445
- | jq -c '.message.usage' 2>/dev/null \\
1446
- | jq -s '{in:(map(.input_tokens//0)|add//0),out:(map(.output_tokens//0)|add//0),cw:(map(.cache_creation_input_tokens//0)|add//0),cr:(map(.cache_read_input_tokens//0)|add//0)}' 2>/dev/null \\
1447
- || echo '{"in":0,"out":0,"cw":0,"cr":0}')
1448
- TOK_IN=$(echo "$TOTALS" | jq -r '.in // 0')
1449
- TOK_OUT=$(echo "$TOTALS" | jq -r '.out // 0')
1450
- TOK_CW=$(echo "$TOTALS" | jq -r '.cw // 0')
1451
- TOK_CR=$(echo "$TOTALS" | jq -r '.cr // 0')
1452
- HAS_TOKENS=$(( TOK_IN + TOK_OUT ))
1453
- if [ "$HAS_TOKENS" != "0" ]; then
1454
- CC_USAGE="{"input_tokens":$TOK_IN,"output_tokens":$TOK_OUT,"cache_creation_input_tokens":$TOK_CW,"cache_read_input_tokens":$TOK_CR}"
1455
- BODY=$(jq -n \\
1456
- --arg event_id "usage_$(date +%s)_$$" \\
1457
- --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1458
- --arg model "\${CC_MODEL:-unknown}" \\
1459
- --arg cc_model "\${CC_MODEL:-}" \\
1460
- --arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
1461
- --argjson cc_usage "$CC_USAGE" \\
1462
- '{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
1463
- + (if $repo != "" then {repo:$repo} else {} end)
1464
- + (if $session_id != "" then {session_id:$session_id} else {} end)
1465
- + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1466
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1467
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1468
- -d "$BODY" --max-time 2 >/dev/null 2>&1
1469
- fi
1470
- ) &
1471
- fi
859
+ // Load config.env into process.env
860
+ if (existsSync(CONFIG_PATH)) {
861
+ try {
862
+ const lines = readFileSync(CONFIG_PATH, 'utf-8').split('\\n');
863
+ for (const line of lines) {
864
+ const trimmed = line.trim();
865
+ if (!trimmed || trimmed.startsWith('#')) continue;
866
+ const eqIdx = trimmed.indexOf('=');
867
+ if (eqIdx < 1) continue;
868
+ const key = trimmed.slice(0, eqIdx).trim();
869
+ let val = trimmed.slice(eqIdx + 1).trim();
870
+ // Strip surrounding quotes
871
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
872
+ val = val.slice(1, -1);
873
+ }
874
+ process.env[key] = val;
875
+ }
876
+ } catch {}
877
+ }
1472
878
 
1473
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-summary" \\
1474
- --data-urlencode "session_id=$SESSION_ID" \\
1475
- -H "Authorization: Bearer $JWT" --max-time 2 2>/dev/null || echo "")
879
+ export const GATEWAY_URL = process.env.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh';
880
+ export const CREDS_PATH = process.env.SYNKRO_CREDENTIALS_PATH || join(HOME, '.synkro', 'credentials.json');
881
+ const LAST_PROMPT_FILE = join(HOME, '.synkro', '.last-prompt');
1476
882
 
1477
- if [ -z "$RESP" ]; then echo '{}'; exit 0; fi
883
+ // \u2500\u2500\u2500 Logging \u2500\u2500\u2500
1478
884
 
1479
- EDITS=$(echo "$RESP" | jq -r '.edits_scanned // 0' 2>/dev/null)
1480
- FINDINGS=$(echo "$RESP" | jq -r '.findings // 0' 2>/dev/null)
1481
- AUTO_FIXED=$(echo "$RESP" | jq -r '.auto_fixed // 0' 2>/dev/null)
1482
- OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
885
+ export function log(msg: string): void {
886
+ process.stderr.write('[synkro] ' + msg + '\\n');
887
+ }
1483
888
 
1484
- if [ "\${EDITS:-0}" = "0" ] || [ -z "$EDITS" ]; then echo '{}'; exit 0; fi
889
+ // \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
1485
890
 
1486
- synkro_load_config
1487
- TAG=$(synkro_tag)
1488
- if [ "\${FINDINGS:-0}" = "0" ] || [ -z "$FINDINGS" ]; then
1489
- SYS_MSG="$TAG stop \u2192 0 issues across \${EDITS} edit(s), session complete"
1490
- else
1491
- SYS_MSG="$TAG stop \u2192 \${FINDINGS} finding(s): \${AUTO_FIXED} auto-fixed, \${OPEN} open"
1492
- fi
891
+ export function loadJwt(): string | null {
892
+ try {
893
+ if (!existsSync(CREDS_PATH)) return null;
894
+ const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
895
+ return creds.access_token || null;
896
+ } catch {
897
+ return null;
898
+ }
899
+ }
1493
900
 
1494
- jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
1495
- exit 0
1496
- `;
1497
- CC_SESSION_START_SCRIPT = `#!/bin/bash
1498
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1499
- . "$SCRIPT_DIR/_synkro-common.sh"
901
+ function decodeJwtExp(jwt: string): number {
902
+ try {
903
+ const parts = jwt.split('.');
904
+ if (parts.length < 2) return 0;
905
+ let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
906
+ while (payload.length % 4) payload += '=';
907
+ const decoded = Buffer.from(payload, 'base64').toString('utf-8');
908
+ const obj = JSON.parse(decoded);
909
+ return obj.exp || 0;
910
+ } catch {
911
+ return 0;
912
+ }
913
+ }
1500
914
 
1501
- JWT=$(synkro_load_jwt)
915
+ export async function refreshJwt(jwt: string): Promise<string> {
916
+ try {
917
+ const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
918
+ const rt = creds.refresh_token;
919
+ if (!rt) return jwt;
1502
920
 
1503
- # Route preamble
1504
- SYNKRO_PORT="\${SYNKRO_CHANNEL_PORT:-8929}"
1505
- PAYLOAD=$(cat)
1506
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1507
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1508
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
921
+ const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
922
+ method: 'POST',
923
+ headers: { 'Content-Type': 'application/json' },
924
+ body: JSON.stringify({ refresh_token: rt }),
925
+ signal: AbortSignal.timeout(4000),
926
+ });
927
+ const data = await resp.json() as any;
928
+ const newAt = data.access_token;
929
+ if (!newAt) return jwt;
930
+
931
+ const newRt = data.refresh_token || rt;
932
+ const existing = (() => {
933
+ try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
934
+ })();
935
+ const updated = { ...existing, access_token: newAt, refresh_token: newRt };
936
+ const tmp = CREDS_PATH + '.synkro.tmp';
937
+ writeFileSync(tmp, JSON.stringify(updated, null, 2));
938
+ renameSync(tmp, CREDS_PATH);
939
+ return newAt;
940
+ } catch {
941
+ return jwt;
942
+ }
943
+ }
1509
944
 
1510
- RESP=""
1511
- if [ -n "$JWT" ]; then
1512
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/hook/config" \\
1513
- --data-urlencode "session_id=\${SESSION_ID:-}" \\
1514
- --data-urlencode "repo=\${GIT_REPO:-}" \\
1515
- -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1516
- if [ -n "$RESP" ]; then
1517
- SYNKRO_SILENT=$(echo "$RESP" | jq -r '.silent_mode // false' 2>/dev/null)
1518
- SYNKRO_POLICY_NAME=$(echo "$RESP" | jq -r '.active_policy_name // empty' 2>/dev/null)
1519
- fi
1520
- fi
945
+ export async function ensureFreshJwt(jwt: string): Promise<string> {
946
+ if (!jwt) return jwt;
947
+ const exp = decodeJwtExp(jwt);
948
+ const now = Math.floor(Date.now() / 1000);
949
+ if (exp - now >= 60) return jwt;
1521
950
 
1522
- if (exec 3<>/dev/tcp/127.0.0.1/"$SYNKRO_PORT") 2>/dev/null; then
1523
- exec 3<&- 3>&- 2>/dev/null || true
1524
- TAG=$(synkro_tag "local")
1525
- ROUTE_LINE="$TAG inference: local-cc (channel reachable on 127.0.0.1:$SYNKRO_PORT)"
1526
- else
1527
- TAG=$(synkro_tag "cloud")
1528
- ROUTE_LINE="$TAG inference: cloud (local-cc channel not reachable)"
1529
- fi
951
+ const lockdir = CREDS_PATH + '.lockdir';
952
+ let acquired = false;
953
+ try {
954
+ mkdirSync(lockdir);
955
+ acquired = true;
956
+ } catch {
957
+ // Another process is refreshing \u2014 wait and re-read
958
+ for (let i = 0; i < 5; i++) {
959
+ await new Promise(r => setTimeout(r, 500));
960
+ if (!existsSync(lockdir)) break;
961
+ }
962
+ // Re-read creds
963
+ const fresh = loadJwt();
964
+ return fresh || jwt;
965
+ }
1530
966
 
1531
- if [ -z "$JWT" ]; then
1532
- jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
1533
- exit 0
1534
- fi
967
+ try {
968
+ // Re-check \u2014 another hook may have just refreshed
969
+ const freshJwt = loadJwt();
970
+ if (freshJwt) {
971
+ const freshExp = decodeJwtExp(freshJwt);
972
+ if (freshExp - Math.floor(Date.now() / 1000) >= 60) return freshJwt;
973
+ }
974
+ return await refreshJwt(jwt);
975
+ } finally {
976
+ if (acquired) {
977
+ try { rmdirSync(lockdir); } catch {}
978
+ }
979
+ }
980
+ }
1535
981
 
1536
- OPEN=0
1537
- if [ -n "$RESP" ]; then
1538
- OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
1539
- fi
982
+ // \u2500\u2500\u2500 Repo Detection \u2500\u2500\u2500
1540
983
 
1541
- if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
1542
- jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
1543
- else
1544
- if [ "$OPEN" = "1" ]; then
1545
- SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 1 open finding in this repo from a prior session."
1546
- else
1547
- SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 \${OPEN} open findings in this repo from prior sessions."
1548
- fi
1549
- jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
1550
- fi
1551
- exit 0
1552
- `;
1553
- CC_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1554
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1555
- . "$SCRIPT_DIR/_synkro-common.sh"
984
+ export function detectRepo(cwd: string): string {
985
+ try {
986
+ const url = execSync('git remote get-url origin', { cwd, timeout: 3000, encoding: 'utf-8' }).trim();
987
+ if (!url) return '';
988
+ return url
989
+ .replace(/^git@[^:]+:/, '')
990
+ .replace(/^https?:\\/\\/[^/]+\\//, '')
991
+ .replace(/\\.git$/, '');
992
+ } catch {
993
+ return '';
994
+ }
995
+ }
1556
996
 
1557
- JWT=$(synkro_load_jwt)
1558
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
997
+ // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
1559
998
 
1560
- PAYLOAD=$(cat)
1561
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1562
- if [ "$TOOL_NAME" != "Bash" ]; then echo '{}'; exit 0; fi
999
+ export async function channelUp(port = 8929): Promise<boolean> {
1000
+ try {
1001
+ await fetch('http://127.0.0.1:' + port, { signal: AbortSignal.timeout(500) });
1002
+ return true;
1003
+ } catch (e: any) {
1004
+ // If we got a connection error vs a response error, check:
1005
+ // fetch throws TypeError for connection refused, but any HTTP response means the port is open
1006
+ if (e?.name === 'TimeoutError') return false;
1007
+ if (e?.cause?.code === 'ECONNREFUSED' || e?.code === 'ECONNREFUSED') return false;
1008
+ // Any other error (like bad response) means the port is actually open
1009
+ return true;
1010
+ }
1011
+ }
1563
1012
 
1564
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1565
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1566
- if [ -z "$SESSION_ID" ] || [ -z "$TOOL_USE_ID" ]; then echo '{}'; exit 0; fi
1013
+ export async function cweChannelUp(): Promise<boolean> {
1014
+ return channelUp(8930);
1015
+ }
1567
1016
 
1568
- # Extract is_error from tool_result and compute command hash for consent tracking
1569
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
1570
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
1571
- CMD_HASH=""
1572
- if [ -n "$CMD" ]; then
1573
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
1574
- fi
1017
+ // \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
1575
1018
 
1576
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
1577
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
1578
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
1019
+ export interface Rule {
1020
+ rule_id: string;
1021
+ text: string;
1022
+ severity: string;
1023
+ category: string;
1024
+ mode: string;
1025
+ }
1579
1026
 
1580
- # Local consent tracking: command ran = user consented
1581
- # On success \u2192 consume (next attempt blocks fresh)
1582
- # On failure \u2192 grant active (retry allowed)
1583
- # Consent tracking: on success \u2192 consume (next run blocks fresh), on error \u2192 grant (retry allowed)
1584
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
1585
- if [ "$IS_ERROR" = "false" ]; then
1586
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
1587
- else
1588
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
1589
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
1590
- fi
1591
- fi
1592
- fi
1027
+ export interface HookConfig {
1028
+ captureDepth: string;
1029
+ tier: string;
1030
+ silent: boolean;
1031
+ policyName: string;
1032
+ rules: Rule[];
1033
+ }
1593
1034
 
1594
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1595
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1596
- -d "$BODY" --max-time 2 >/dev/null 2>&1 || true
1035
+ export async function loadConfig(jwt: string, query?: string): Promise<HookConfig> {
1036
+ const config: HookConfig = {
1037
+ captureDepth: 'local_only',
1038
+ tier: 'standard',
1039
+ silent: false,
1040
+ policyName: '',
1041
+ rules: [],
1042
+ };
1043
+ try {
1044
+ const url = GATEWAY_URL + '/api/v1/hook/config' + (query ? '?' + query : '');
1045
+ const resp = await fetch(url, {
1046
+ headers: { Authorization: 'Bearer ' + jwt },
1047
+ signal: AbortSignal.timeout(4000),
1048
+ });
1049
+ const data = await resp.json() as any;
1050
+ config.captureDepth = data.capture_depth || 'local_only';
1051
+ config.tier = data.tier || 'standard';
1052
+ config.silent = data.silent_mode === true || data.silent_mode === 'true';
1053
+ config.policyName = data.active_policy_name || '';
1054
+ if (Array.isArray(data.rules)) {
1055
+ config.rules = data.rules
1056
+ .filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
1057
+ .map((r: any) => ({
1058
+ rule_id: r.rule_id || '',
1059
+ text: r.text || '',
1060
+ severity: r.severity || '',
1061
+ category: r.category || '',
1062
+ mode: r.mode || 'blocking',
1063
+ }));
1064
+ }
1065
+ } catch {}
1066
+ return config;
1067
+ }
1597
1068
 
1598
- echo '{}'
1599
- exit 0
1600
- `;
1601
- CC_CVE_SCAN_SCRIPT = `#!/bin/bash
1602
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1603
- . "$SCRIPT_DIR/_synkro-common.sh"
1069
+ // \u2500\u2500\u2500 Routing \u2500\u2500\u2500
1604
1070
 
1605
- JWT=$(synkro_load_jwt)
1606
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1607
- synkro_ensure_fresh_jwt
1071
+ export async function route(config: HookConfig): Promise<'local' | 'cloud'> {
1072
+ if (config.captureDepth === 'local_only') return 'local';
1073
+ if (await channelUp()) return 'local';
1074
+ return 'cloud';
1075
+ }
1608
1076
 
1609
- PAYLOAD=$(cat)
1610
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1077
+ export async function cweRoute(config: HookConfig): Promise<'local' | 'cloud'> {
1078
+ if (config.captureDepth === 'local_only') return 'local';
1079
+ if (await cweChannelUp()) return 'local';
1080
+ return 'cloud';
1081
+ }
1611
1082
 
1612
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1613
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1083
+ // \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
1614
1084
 
1615
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1616
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1085
+ export function tag(rt: string, config: HookConfig): string {
1086
+ if (config.silent) return '[synkro:silent]';
1087
+ const rs = config.policyName || 'all';
1088
+ return '[synkro:' + rt + ':' + rs + ']';
1089
+ }
1617
1090
 
1618
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1619
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1091
+ // \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
1620
1092
 
1621
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1622
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
1093
+ function spawnGrade(surface: string, prompt: string, envOverride?: Record<string, string>, timeoutMs = 22000): Promise<string> {
1094
+ return new Promise((resolve, reject) => {
1095
+ const cliBin = process.env.SYNKRO_CLI_BIN;
1096
+ let cmd: string;
1097
+ let args: string[];
1623
1098
 
1624
- DEPS_JSON="{}"
1625
- _PKG_DIR=$(dirname "$FILE_PATH")
1626
- while [ "$_PKG_DIR" != "/" ]; do
1627
- if [ -f "$_PKG_DIR/package.json" ]; then
1628
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
1629
- break
1630
- fi
1631
- _PKG_DIR=$(dirname "$_PKG_DIR")
1632
- done
1099
+ if (cliBin && existsSync(cliBin)) {
1100
+ // Use the CLI binary directly with bun/node
1101
+ cmd = 'node';
1102
+ args = [cliBin, 'grade', surface];
1103
+ } else {
1104
+ cmd = 'synkro';
1105
+ args = ['grade', surface];
1106
+ }
1633
1107
 
1634
- synkro_load_config
1635
- ROUTE=$(synkro_route)
1636
- TAG=$(synkro_tag "$ROUTE")
1108
+ const child = spawn(cmd, args, {
1109
+ stdio: ['pipe', 'pipe', 'pipe'],
1110
+ env: { ...process.env, ...envOverride },
1111
+ });
1637
1112
 
1638
- if [ "$SYNKRO_SILENT" = "true" ]; then echo '{}'; exit 0; fi
1113
+ let stdout = '';
1114
+ let stderr = '';
1115
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
1116
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
1117
+ child.stdin.write(prompt);
1118
+ child.stdin.end();
1119
+
1120
+ const timer = setTimeout(() => {
1121
+ child.kill();
1122
+ reject(new Error('SYNKRO_GRADE_TIMEOUT'));
1123
+ }, timeoutMs);
1124
+
1125
+ child.on('close', (code: number | null) => {
1126
+ clearTimeout(timer);
1127
+ if (code === 0) resolve(stdout);
1128
+ else reject(new Error(stderr || 'exit ' + code));
1129
+ });
1130
+ child.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
1131
+ });
1132
+ }
1639
1133
 
1640
- BASENAME=$(basename "$FILE_PATH")
1134
+ export async function localGrade(surface: string, prompt: string): Promise<string> {
1135
+ if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1136
+ return spawnGrade(surface, prompt);
1137
+ }
1641
1138
 
1642
- BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" --argjson deps "$DEPS_JSON" \\
1643
- '{file_path:$fp, content:$c, dependencies:$deps}')
1139
+ export async function localGradeCwe(prompt: string): Promise<string> {
1140
+ if (!(await cweChannelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1141
+ return spawnGrade('cwe', prompt, { SYNKRO_CHANNEL_PORT: '8930' }, 12000);
1142
+ }
1644
1143
 
1645
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/cve-scan" \\
1646
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1647
- -d "$BODY" --max-time 6 2>/dev/null || echo "")
1144
+ // \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
1648
1145
 
1649
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1650
- echo '{}'; exit 0
1651
- fi
1146
+ export interface Verdict {
1147
+ ok: boolean;
1148
+ reason: string;
1149
+ ruleId: string;
1150
+ ruleMode: string;
1151
+ severity: string;
1152
+ category: string;
1153
+ }
1652
1154
 
1653
- CVE_COUNT=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1654
- if [ "$CVE_COUNT" -gt 0 ] 2>/dev/null; then
1655
- CVE_CRIT=$(echo "$RESP" | jq '[.findings[] | select((.severity | tonumber? // 0) >= 7.0)] | length' 2>/dev/null || echo "0")
1656
- CRIT_PKGS=$(echo "$RESP" | jq -r '[.findings[] | select((.severity | tonumber? // 0) >= 7.0) | .package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
1657
- ALL_PKGS=$(echo "$RESP" | jq -r '[.findings[].package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
1658
- ALL_TOTAL=$(echo "$RESP" | jq -r '[.findings[].package] | unique | length' 2>/dev/null || echo "0")
1659
- [ "$CVE_COUNT" = "1" ] && LABEL="advisory" || LABEL="advisories"
1660
- if [ "$CVE_CRIT" -gt 0 ]; then
1661
- [ "$ALL_TOTAL" -gt 3 ] && CRIT_PKGS="\${CRIT_PKGS}, ..."
1662
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL}, \${CVE_CRIT} critical/high (\${CRIT_PKGS})" '{systemMessage: $m}'
1663
- else
1664
- [ "$ALL_TOTAL" -gt 3 ] && ALL_PKGS="\${ALL_PKGS}, ..."
1665
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL} (\${ALL_PKGS})" '{systemMessage: $m}'
1666
- fi
1667
- else
1668
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] clean" '{systemMessage: $m}'
1669
- fi
1670
- exit 0
1671
- `;
1672
- CC_CWE_SCAN_SCRIPT = `#!/bin/bash
1673
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1674
- . "$SCRIPT_DIR/_synkro-common.sh"
1155
+ export function parseVerdict(resp: string): Verdict {
1156
+ const verdict: Verdict = {
1157
+ ok: true,
1158
+ reason: '',
1159
+ ruleId: '',
1160
+ ruleMode: '',
1161
+ severity: 'low',
1162
+ category: 'clean',
1163
+ };
1675
1164
 
1676
- JWT=$(synkro_load_jwt)
1677
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1678
- synkro_ensure_fresh_jwt
1165
+ // Flatten newlines for easier regex
1166
+ const flat = resp.replace(/\\n/g, ' ');
1167
+ const outerMatch = flat.match(/<synkro-verdict>(.*)<\\/synkro-verdict>/);
1168
+ if (!outerMatch) return verdict;
1169
+ const inner = outerMatch[1];
1679
1170
 
1680
- PAYLOAD=$(cat)
1681
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1171
+ const okMatch = inner.match(/<ok>(.*?)<\\/ok>/);
1172
+ if (okMatch) verdict.ok = okMatch[1].trim() !== 'false';
1682
1173
 
1683
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1684
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1174
+ const reasonMatch = inner.match(/<reason>(.*?)<\\/reason>/) || inner.match(/<reasoning>(.*?)<\\/reasoning>/);
1175
+ if (reasonMatch) verdict.reason = reasonMatch[1].trim();
1685
1176
 
1686
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1687
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1177
+ if (!verdict.ok) {
1178
+ const ruleIdMatch = inner.match(/<rule_id>(.*?)<\\/rule_id>/);
1179
+ const ruleModeMatch = inner.match(/<rule_mode>(.*?)<\\/rule_mode>/);
1180
+ const sevMatch = inner.match(/<risk_level>(.*?)<\\/risk_level>/);
1181
+
1182
+ if (ruleIdMatch) {
1183
+ verdict.ruleId = ruleIdMatch[1].trim();
1184
+ } else {
1185
+ // Try to find inside a <violation> block
1186
+ const violationMatch = inner.match(/<violation>(.*?)<\\/violation>/);
1187
+ if (violationMatch) {
1188
+ const vBlock = violationMatch[1];
1189
+ const vRuleId = vBlock.match(/<rule_id>(.*?)<\\/rule_id>/);
1190
+ if (vRuleId) verdict.ruleId = vRuleId[1].trim();
1191
+ if (!verdict.reason) {
1192
+ const vReason = vBlock.match(/<reason>(.*?)<\\/reason>/);
1193
+ if (vReason) verdict.reason = vReason[1].trim();
1194
+ }
1195
+ if (!sevMatch) {
1196
+ const vSev = vBlock.match(/<severity>(.*?)<\\/severity>/);
1197
+ if (vSev) verdict.severity = vSev[1].trim();
1198
+ }
1199
+ }
1200
+ }
1688
1201
 
1689
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1690
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1202
+ if (ruleModeMatch) verdict.ruleMode = ruleModeMatch[1].trim();
1203
+ if (sevMatch) verdict.severity = sevMatch[1].trim();
1204
+ verdict.severity = verdict.severity || 'high';
1691
1205
 
1692
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1693
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
1206
+ const catMatch = inner.match(/<category>(.*?)<\\/category>/);
1207
+ verdict.category = catMatch ? catMatch[1].trim() : 'uncategorized';
1694
1208
 
1695
- synkro_load_config
1696
- ROUTE=$(synkro_route)
1697
- TAG=$(synkro_tag "$ROUTE")
1209
+ // Fallback: extract rule ID from reason text
1210
+ if (!verdict.ruleId && verdict.reason) {
1211
+ const rMatch = verdict.reason.match(/[Rr]\\d{3}/);
1212
+ if (rMatch) verdict.ruleId = rMatch[0];
1213
+ }
1214
+ }
1698
1215
 
1699
- if [ "$SYNKRO_SILENT" = "true" ]; then echo '{}'; exit 0; fi
1216
+ return verdict;
1217
+ }
1218
+
1219
+ // \u2500\u2500\u2500 Telemetry Dispatch \u2500\u2500\u2500
1220
+
1221
+ export function dispatchCapture(
1222
+ jwt: string,
1223
+ hookType: string,
1224
+ verdictStr: string,
1225
+ severity: string,
1226
+ category: string,
1227
+ toolName: string,
1228
+ repo: string,
1229
+ sessionId: string,
1230
+ captureDepth: string,
1231
+ opts?: {
1232
+ command?: string;
1233
+ reasoning?: string;
1234
+ rulesChecked?: Rule[] | string;
1235
+ violatedRules?: string[];
1236
+ recentUserMessages?: string[];
1237
+ ccModel?: string;
1238
+ },
1239
+ ): void {
1240
+ // Fire-and-forget
1241
+ const eventId = 'evt_' + Date.now() + '_' + process.pid;
1242
+ const model = opts?.ccModel || 'unknown';
1243
+ const sendFull =
1244
+ captureDepth === 'full' ||
1245
+ (captureDepth === 'evidence_on_violation' && ['block', 'warning', 'deny'].includes(verdictStr));
1246
+
1247
+ const body: Record<string, any> = {
1248
+ capture_type: 'local_verdict',
1249
+ event_id: eventId,
1250
+ hook_type: hookType,
1251
+ verdict: verdictStr,
1252
+ severity,
1253
+ category,
1254
+ cc_model: model,
1255
+ model,
1256
+ tool_name: toolName,
1257
+ };
1258
+ if (repo) body.repo = repo;
1259
+ if (sessionId) body.session_id = sessionId;
1700
1260
 
1701
- BASENAME=$(basename "$FILE_PATH")
1702
- FILE_EXT=".\${FILE_PATH##*.}"
1261
+ if (sendFull && opts) {
1262
+ body.capture_depth = captureDepth;
1263
+ if (opts.command) body.command = opts.command;
1264
+ if (opts.reasoning) body.reasoning = opts.reasoning;
1265
+ if (opts.rulesChecked) body.rules_checked = opts.rulesChecked;
1266
+ if (opts.violatedRules) body.violated_rules = opts.violatedRules;
1267
+ if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
1268
+ }
1703
1269
 
1704
- # \u2500\u2500\u2500 Helper: format CWE findings into system message \u2500\u2500\u2500
1705
- format_cwe_result() {
1706
- local count="$1" crit="$2" ids="$3" total="$4"
1707
- [ "$count" = "1" ] && local label="match" || local label="matches"
1708
- if [ "$crit" -gt 0 ] 2>/dev/null; then
1709
- [ "$total" -gt 3 ] && ids="\${ids}, ..."
1710
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] \${count} CWE \${label}, \${crit} critical (\${ids})" '{systemMessage: $m}'
1711
- else
1712
- [ "$total" -gt 3 ] && ids="\${ids}, ..."
1713
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] \${count} CWE \${label} (\${ids})" '{systemMessage: $m}'
1714
- fi
1270
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
1271
+ method: 'POST',
1272
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1273
+ body: JSON.stringify(body),
1274
+ signal: AbortSignal.timeout(3000),
1275
+ }).catch(() => {});
1715
1276
  }
1716
1277
 
1717
- if [ "$ROUTE" = "local" ]; then
1718
- # \u2500\u2500\u2500 Local CWE scan: deterministic Top 25 filter + local-cc grader \u2500\u2500\u2500
1719
- CWE_RULES=$(curl -sS -X GET "\${GATEWAY_URL}/api/v1/cwe-rules?ext=$FILE_EXT" \\
1720
- -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
1721
- CWE_LIST=$(echo "$CWE_RULES" | jq -c '.rules // []' 2>/dev/null || echo "[]")
1722
- CWE_RULE_COUNT=$(echo "$CWE_LIST" | jq 'length' 2>/dev/null || echo "0")
1723
- if [ "$CWE_RULE_COUNT" -eq 0 ] 2>/dev/null; then
1724
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean (no CWEs for $FILE_EXT)" '{systemMessage: $m}'
1725
- exit 0
1726
- fi
1727
-
1728
- GRADER_FILE=$(mktemp -t synkro-cwescan.XXXXXX)
1729
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1730
- printf 'File: %s\\nContent (first 4000 chars):\\n%s\\n\\nCWE rules to check against:\\n%s\\n' "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "$CWE_LIST" > "$GRADER_FILE"
1278
+ // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
1731
1279
 
1732
- CC_RESP=$(synkro_local_grade_cwe < "$GRADER_FILE" 2>&1)
1733
- if [ $? -ne 0 ]; then
1734
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] pass: local grader unavailable" '{systemMessage: $m}'
1735
- exit 0
1736
- fi
1737
- synkro_parse_local_verdict "$CC_RESP"
1738
-
1739
- if [ "$LOCAL_OK" = "false" ]; then
1740
- CWE_IDS=""
1741
- CWE_COUNT=0
1742
- CWE_CRIT=0
1743
- while IFS= read -r vid; do
1744
- [ -z "$vid" ] && continue
1745
- CWE_COUNT=$((CWE_COUNT + 1))
1746
- cwe_tag=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE "<violation>[^<]*<rule_id>$vid</rule_id>[^<]*<severity>[^<]*</severity>" | head -1)
1747
- sev=$(printf '%s' "$cwe_tag" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p')
1748
- [ "$sev" = "critical" ] && CWE_CRIT=$((CWE_CRIT + 1))
1749
- CWE_ID=$(echo "$vid" | sed 's/cwe-/CWE-/')
1750
- [ "$CWE_COUNT" -le 3 ] && { [ -n "$CWE_IDS" ] && CWE_IDS="$CWE_IDS, $CWE_ID" || CWE_IDS="$CWE_ID"; }
1751
- done <<< "$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE '<rule_id>[^<]+</rule_id>' | sed 's/<[^>]*>//g')"
1752
-
1753
- if [ "$CWE_COUNT" -gt 0 ]; then
1754
- format_cwe_result "$CWE_COUNT" "$CWE_CRIT" "$CWE_IDS" "$CWE_COUNT"
1755
- else
1756
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
1757
- fi
1758
- else
1759
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
1760
- fi
1761
- exit 0
1762
- fi
1280
+ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1281
+ if (!ruleId || !rules.length) return 'blocking';
1282
+ const matched = rules.filter(r => r.rule_id === ruleId);
1283
+ if (matched.some(r => r.mode === 'blocking')) return 'blocking';
1284
+ return (matched[0]?.mode as 'blocking' | 'audit') || 'blocking';
1285
+ }
1763
1286
 
1764
- # \u2500\u2500\u2500 Cloud CWE scan: cosine similarity against full CWE catalog \u2500\u2500\u2500
1765
- BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" \\
1766
- '{file_path:$fp, content:$c}')
1287
+ // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1767
1288
 
1768
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/cwe-scan" \\
1769
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1770
- -d "$BODY" --max-time 8 2>/dev/null || echo "")
1289
+ export function reconstructContent(toolName: string, toolInput: any, filePath: string): string {
1290
+ switch (toolName) {
1291
+ case 'Write':
1292
+ return toolInput.content || '';
1293
+ case 'Edit': {
1294
+ let content = '';
1295
+ try {
1296
+ if (filePath && existsSync(filePath)) {
1297
+ content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1298
+ }
1299
+ } catch {}
1300
+ const oldStr = toolInput.old_string || '';
1301
+ const newStr = toolInput.new_string || '';
1302
+ if (oldStr && content.includes(oldStr)) {
1303
+ return content.replace(oldStr, newStr);
1304
+ }
1305
+ return content || newStr;
1306
+ }
1307
+ case 'MultiEdit': {
1308
+ let content = '';
1309
+ try {
1310
+ if (filePath && existsSync(filePath)) {
1311
+ content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1312
+ }
1313
+ } catch {}
1314
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
1315
+ for (const edit of edits) {
1316
+ if (!edit || typeof edit !== 'object') continue;
1317
+ const old = edit.old_string || '';
1318
+ const nw = edit.new_string || '';
1319
+ if (old && content.includes(old)) {
1320
+ content = content.replace(old, nw);
1321
+ }
1322
+ }
1323
+ return content;
1324
+ }
1325
+ case 'NotebookEdit':
1326
+ return toolInput.new_source || '';
1327
+ default:
1328
+ return '';
1329
+ }
1330
+ }
1771
1331
 
1772
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1773
- echo '{}'; exit 0
1774
- fi
1332
+ // \u2500\u2500\u2500 HTTP with Retry \u2500\u2500\u2500
1775
1333
 
1776
- CWE_COUNT=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1777
- if [ "$CWE_COUNT" -gt 0 ] 2>/dev/null; then
1778
- CWE_CRIT=$(echo "$RESP" | jq '[.findings[] | select(.severity == "critical" or .mode == "blocking")] | length' 2>/dev/null || echo "0")
1779
- CWE_IDS=$(echo "$RESP" | jq -r '[.findings[:3][] | .cwe] | join(", ")' 2>/dev/null || echo "")
1780
- CWE_TOTAL=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1781
- format_cwe_result "$CWE_COUNT" "$CWE_CRIT" "$CWE_IDS" "$CWE_TOTAL"
1782
- else
1783
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
1784
- fi
1785
- exit 0
1786
- `;
1787
- CC_TRANSCRIPT_SYNC_SCRIPT = `#!/bin/bash
1788
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1789
- . "$SCRIPT_DIR/_synkro-common.sh"
1334
+ export async function postWithRetry(url: string, body: any, jwt: string, timeout = 8000): Promise<any> {
1335
+ let currentJwt = jwt;
1336
+ let resp: Response;
1337
+ try {
1338
+ resp = await fetch(url, {
1339
+ method: 'POST',
1340
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + currentJwt },
1341
+ body: JSON.stringify(body),
1342
+ signal: AbortSignal.timeout(timeout),
1343
+ });
1344
+ } catch {
1345
+ return null;
1346
+ }
1790
1347
 
1791
- JWT=$(synkro_load_jwt)
1792
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1348
+ let data: any;
1349
+ try { data = await resp.json(); } catch { return null; }
1793
1350
 
1794
- PAYLOAD=$(cat)
1795
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1796
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1797
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1351
+ // Retry on token expiry
1352
+ if (data?.detail && (data.detail.includes('Token has expired') || data.detail.includes('Invalid or expired token'))) {
1353
+ try {
1354
+ currentJwt = await refreshJwt(currentJwt);
1355
+ const resp2 = await fetch(url, {
1356
+ method: 'POST',
1357
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + currentJwt },
1358
+ body: JSON.stringify(body),
1359
+ signal: AbortSignal.timeout(timeout),
1360
+ });
1361
+ data = await resp2.json();
1362
+ } catch {
1363
+ return null;
1364
+ }
1365
+ }
1798
1366
 
1799
- if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
1800
- echo '{}'; exit 0
1801
- fi
1367
+ return data;
1368
+ }
1802
1369
 
1803
- # Usage telemetry \u2014 last turn only (metadata, ungated by privacy/consent)
1804
- _LAST_ASST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1805
- if [ -n "$_LAST_ASST" ]; then
1806
- _CC_MODEL=$(echo "$_LAST_ASST" | jq -r '.message.model // empty' 2>/dev/null)
1807
- _TI=$(echo "$_LAST_ASST" | jq -r '.message.usage.input_tokens // 0' 2>/dev/null)
1808
- _TO=$(echo "$_LAST_ASST" | jq -r '.message.usage.output_tokens // 0' 2>/dev/null)
1809
- _TCW=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_creation_input_tokens // 0' 2>/dev/null)
1810
- _TCR=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_read_input_tokens // 0' 2>/dev/null)
1811
- if [ "\${_TI:-0}" != "0" ] || [ "\${_TO:-0}" != "0" ]; then
1812
- (
1813
- _USAGE="{\\"input_tokens\\":$_TI,\\"output_tokens\\":$_TO,\\"cache_creation_input_tokens\\":$_TCW,\\"cache_read_input_tokens\\":$_TCR}"
1814
- _BODY=$(jq -n \\
1815
- --arg event_id "usage_$(date +%s)_$$" \\
1816
- --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1817
- --arg model "\${_CC_MODEL:-unknown}" \\
1818
- --arg cc_model "\${_CC_MODEL:-}" \\
1819
- --arg session_id "$SESSION_ID" \\
1820
- --argjson cc_usage "$_USAGE" \\
1821
- '{capture_type:"usage_tick",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage,session_id:$session_id}
1822
- + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1823
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1824
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1825
- -d "$_BODY" --max-time 2 >/dev/null 2>&1
1826
- ) &
1827
- fi
1828
- fi
1370
+ // \u2500\u2500\u2500 Read Stdin \u2500\u2500\u2500
1829
1371
 
1830
- # Transcript sync below is gated by consent + capture depth
1831
- if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
1372
+ export async function readStdin(): Promise<string> {
1373
+ const chunks: Buffer[] = [];
1374
+ for await (const chunk of process.stdin) {
1375
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1376
+ }
1377
+ return Buffer.concat(chunks).toString('utf-8');
1378
+ }
1832
1379
 
1833
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1834
- if [ -z "$GIT_REPO" ]; then echo '{}'; exit 0; fi
1380
+ // \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
1835
1381
 
1836
- # Check capture depth \u2014 skip in local_only
1837
- CONFIG_RESP=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config" -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1838
- CAPTURE_DEPTH=$(echo "$CONFIG_RESP" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
1839
- if [ "$CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
1382
+ export interface TranscriptContext {
1383
+ userIntent: string;
1384
+ recentUserMessages: string[];
1385
+ recentMessages: Array<{ type: string; text: string }>;
1386
+ recentActions: Array<{ tool: string; input: string }>;
1387
+ sessionSummary: string;
1388
+ ccModel: string;
1389
+ ccUsage: Record<string, any>;
1390
+ }
1840
1391
 
1841
- OFFSET_DIR="$HOME/.synkro/.transcript-offsets"
1842
- mkdir -p "$OFFSET_DIR" 2>/dev/null || true
1843
- OFFSET_FILE="$OFFSET_DIR/$SESSION_ID"
1844
- OFFSET=0
1845
- [ -f "$OFFSET_FILE" ] && OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
1392
+ export function extractTranscript(transcriptPath: string | undefined): TranscriptContext {
1393
+ const ctx: TranscriptContext = {
1394
+ userIntent: '',
1395
+ recentUserMessages: [],
1396
+ recentMessages: [],
1397
+ recentActions: [],
1398
+ sessionSummary: '',
1399
+ ccModel: '',
1400
+ ccUsage: {},
1401
+ };
1846
1402
 
1847
- TOTAL_LINES=$(wc -l < "$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ')
1848
- if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then echo '{}'; exit 0; fi
1403
+ if (!transcriptPath || !existsSync(transcriptPath)) return ctx;
1849
1404
 
1850
- DELTA=$((TOTAL_LINES - OFFSET))
1851
- START_LINE=$((OFFSET + 1))
1852
- [ "$DELTA" -gt 200 ] && START_LINE=$((TOTAL_LINES - 199))
1405
+ try {
1406
+ const raw = readFileSync(transcriptPath, 'utf-8');
1407
+ const lines = raw.split('\\n').filter(l => l.trim());
1408
+ // Take the last 400 lines
1409
+ const tail = lines.slice(-400);
1410
+
1411
+ const parsed: any[] = [];
1412
+ for (const line of tail) {
1413
+ try { parsed.push(JSON.parse(line)); } catch {}
1414
+ }
1853
1415
 
1854
- MESSAGES=$(tail -n +"$START_LINE" "$TRANSCRIPT_PATH" 2>/dev/null | jq -c --argjson base_idx "$((START_LINE - 1))" '
1855
- . as $line |
1856
- if ($line.type == "user" or $line.type == "assistant") then
1857
- {
1858
- message_index: (input_line_number + $base_idx),
1859
- type: $line.type,
1860
- content: (if $line.type == "user" then ($line.message.content | if type == "string" then .[0:8000] else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:8000]) end) else ([$line.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:8000]) end),
1861
- tool_calls: (if $line.type == "assistant" then [$line.message.content[]? | select(.type == "tool_use") | {name, input: (.input | tostring | .[0:500]), id}] else null end | if . == null or length == 0 then null else . end),
1862
- model: ($line.message.model // null),
1863
- usage: (if $line.type == "assistant" and $line.message.usage then {input_tokens: $line.message.usage.input_tokens, output_tokens: $line.message.usage.output_tokens, cache_creation_input_tokens: $line.message.usage.cache_creation_input_tokens, cache_read_input_tokens: $line.message.usage.cache_read_input_tokens} else null end)
1864
- }
1865
- else empty end
1866
- ' 2>/dev/null | jq -s '.' 2>/dev/null)
1867
-
1868
- if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
1869
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
1870
- echo '{}'; exit 0
1871
- fi
1416
+ // Recent user messages (last 5)
1417
+ const userMsgs: string[] = [];
1418
+ for (const entry of parsed) {
1419
+ if (entry.type !== 'user') continue;
1420
+ const content = entry.message?.content;
1421
+ let text = '';
1422
+ if (typeof content === 'string') text = content;
1423
+ else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
1424
+ if (text) userMsgs.push(text);
1425
+ }
1426
+ ctx.recentUserMessages = userMsgs.slice(-5);
1427
+ ctx.userIntent = ctx.recentUserMessages[ctx.recentUserMessages.length - 1] || '';
1428
+
1429
+ // Recent messages (last 10, user + assistant)
1430
+ const msgs: Array<{ type: string; text: string }> = [];
1431
+ for (const entry of parsed) {
1432
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
1433
+ const content = entry.message?.content;
1434
+ let text = '';
1435
+ if (typeof content === 'string') text = content.slice(0, 500);
1436
+ else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
1437
+ msgs.push({ type: entry.type, text });
1438
+ }
1439
+ ctx.recentMessages = msgs.slice(-10);
1440
+
1441
+ // Recent tool calls (last 5)
1442
+ const actions: Array<{ tool: string; input: string }> = [];
1443
+ for (const entry of parsed) {
1444
+ if (entry.type !== 'assistant') continue;
1445
+ const content = entry.message?.content;
1446
+ if (!Array.isArray(content)) continue;
1447
+ for (const block of content) {
1448
+ if (block.type !== 'tool_use') continue;
1449
+ actions.push({
1450
+ tool: block.name || '',
1451
+ input: JSON.stringify(block.input || {}).slice(0, 200),
1452
+ });
1453
+ }
1454
+ }
1455
+ ctx.recentActions = actions.slice(-5);
1456
+
1457
+ // Session summary
1458
+ for (const entry of parsed) {
1459
+ if (entry.type === 'summary' && entry.summary) {
1460
+ ctx.sessionSummary = entry.summary;
1461
+ }
1462
+ }
1463
+
1464
+ // CC model
1465
+ const assistantEntries = parsed.filter(e => e.type === 'assistant');
1466
+ if (assistantEntries.length > 0) {
1467
+ const last = assistantEntries[assistantEntries.length - 1];
1468
+ ctx.ccModel = last.message?.model || '';
1469
+ const usage = last.message?.usage;
1470
+ if (usage) {
1471
+ ctx.ccUsage = {
1472
+ input_tokens: usage.input_tokens,
1473
+ output_tokens: usage.output_tokens,
1474
+ cache_creation_input_tokens: usage.cache_creation_input_tokens,
1475
+ cache_read_input_tokens: usage.cache_read_input_tokens,
1476
+ };
1477
+ }
1478
+ }
1479
+ } catch {}
1480
+
1481
+ return ctx;
1482
+ }
1483
+
1484
+ // \u2500\u2500\u2500 Last Prompt \u2500\u2500\u2500
1485
+
1486
+ export function readLastPrompt(): string {
1487
+ try {
1488
+ if (!existsSync(LAST_PROMPT_FILE)) return '';
1489
+ return readFileSync(LAST_PROMPT_FILE, 'utf-8').trim();
1490
+ } catch {
1491
+ return '';
1492
+ }
1493
+ }
1494
+
1495
+ // \u2500\u2500\u2500 Find Nearest Package Dependencies \u2500\u2500\u2500
1496
+
1497
+ export function findNearestDeps(filePath: string): Record<string, string> {
1498
+ let dir = dirname(filePath);
1499
+ while (dir !== '/' && dir !== '.') {
1500
+ const pkg = join(dir, 'package.json');
1501
+ if (existsSync(pkg)) {
1502
+ try {
1503
+ const data = JSON.parse(readFileSync(pkg, 'utf-8'));
1504
+ return { ...(data.dependencies || {}), ...(data.devDependencies || {}) };
1505
+ } catch {}
1506
+ }
1507
+ const parent = dirname(dir);
1508
+ if (parent === dir) break;
1509
+ dir = parent;
1510
+ }
1511
+ return {};
1512
+ }
1513
+
1514
+ // \u2500\u2500\u2500 Consent Tracking \u2500\u2500\u2500
1515
+
1516
+ const CONSENT_FILE = join(HOME, '.synkro', '.local-consent');
1517
+
1518
+ export function consentGrant(sessionId: string, hash: string): void {
1519
+ try {
1520
+ const dir = dirname(CONSENT_FILE);
1521
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1522
+ const line = sessionId + '\\t' + hash + '\\tactive\\n';
1523
+ const { appendFileSync } = require('node:fs');
1524
+ appendFileSync(CONSENT_FILE, line, 'utf-8');
1525
+ } catch {}
1526
+ }
1527
+
1528
+ export function consentHasActive(sessionId: string, hash: string): boolean {
1529
+ try {
1530
+ if (!existsSync(CONSENT_FILE)) return false;
1531
+ const content = readFileSync(CONSENT_FILE, 'utf-8');
1532
+ return content.includes(sessionId + '\\t' + hash + '\\tactive');
1533
+ } catch {
1534
+ return false;
1535
+ }
1536
+ }
1537
+
1538
+ export function consentConsume(sessionId: string, hash: string): void {
1539
+ try {
1540
+ if (!existsSync(CONSENT_FILE)) return;
1541
+ const content = readFileSync(CONSENT_FILE, 'utf-8');
1542
+ const target = sessionId + '\\t' + hash + '\\tactive';
1543
+ const replacement = sessionId + '\\t' + hash + '\\tconsumed';
1544
+ const updated = content.split('\\n').map(l => l === target ? replacement : l).join('\\n');
1545
+ writeFileSync(CONSENT_FILE, updated, 'utf-8');
1546
+ } catch {}
1547
+ }
1548
+
1549
+ // \u2500\u2500\u2500 Crypto Hash \u2500\u2500\u2500
1550
+
1551
+ export function hashCommand(cmd: string): string {
1552
+ const { createHash } = require('node:crypto');
1553
+ return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
1554
+ }
1555
+
1556
+ // \u2500\u2500\u2500 Transcript Usage Aggregation \u2500\u2500\u2500
1557
+
1558
+ export function aggregateUsage(transcriptPath: string): { model: string; totals: Record<string, number> } {
1559
+ const result = { model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } };
1560
+ if (!transcriptPath || !existsSync(transcriptPath)) return result;
1561
+ try {
1562
+ const raw = readFileSync(transcriptPath, 'utf-8');
1563
+ const lines = raw.split('\\n').filter(l => l.trim());
1564
+ for (const line of lines) {
1565
+ try {
1566
+ const entry = JSON.parse(line);
1567
+ if (entry.type !== 'assistant') continue;
1568
+ result.model = entry.message?.model || result.model;
1569
+ const u = entry.message?.usage;
1570
+ if (u) {
1571
+ result.totals.in += u.input_tokens || 0;
1572
+ result.totals.out += u.output_tokens || 0;
1573
+ result.totals.cw += u.cache_creation_input_tokens || 0;
1574
+ result.totals.cr += u.cache_read_input_tokens || 0;
1575
+ }
1576
+ } catch {}
1577
+ }
1578
+ } catch {}
1579
+ return result;
1580
+ }
1581
+
1582
+ // \u2500\u2500\u2500 Output Helpers \u2500\u2500\u2500
1583
+
1584
+ export function outputJson(obj: any): void {
1585
+ console.log(JSON.stringify(obj));
1586
+ }
1587
+
1588
+ export function outputEmpty(): void {
1589
+ console.log('{}');
1590
+ }
1591
+ `;
1592
+ EDIT_PRECHECK_TS = `#!/usr/bin/env bun
1593
+ import {
1594
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
1595
+ parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
1596
+ readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
1597
+ outputJson, outputEmpty, GATEWAY_URL,
1598
+ type HookConfig, type Rule,
1599
+ } from './_synkro-common.ts';
1600
+ import { existsSync, readFileSync } from 'node:fs';
1601
+ import { basename, dirname, join } from 'node:path';
1602
+
1603
+ async function main() {
1604
+ try {
1605
+ const input = await readStdin();
1606
+ if (!input.trim()) { outputEmpty(); return; }
1607
+
1608
+ const payload = JSON.parse(input);
1609
+ const toolName = payload.tool_name || '';
1610
+ if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
1611
+ outputEmpty();
1612
+ return;
1613
+ }
1614
+
1615
+ const toolInput = payload.tool_input || {};
1616
+ const sessionId = payload.session_id || '';
1617
+ const toolUseId = payload.tool_use_id || '';
1618
+ const cwd = payload.cwd || '';
1619
+ const permissionMode = payload.permission_mode || '';
1620
+ const transcriptPath = payload.transcript_path || '';
1621
+
1622
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1623
+ if (!filePath) { outputEmpty(); return; }
1624
+
1625
+ const fileShort = basename(filePath);
1626
+ log('editGuard checking: ' + fileShort);
1627
+
1628
+ const gitRepo = detectRepo(cwd || '.');
1629
+
1630
+ let jwt = loadJwt();
1631
+ if (!jwt) { outputEmpty(); return; }
1632
+ jwt = await ensureFreshJwt(jwt);
1633
+
1634
+ // Reconstruct proposed content
1635
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1636
+ if (!proposed) { outputEmpty(); return; }
1637
+
1638
+ // Build diff field
1639
+ let diffField: any = null;
1640
+ if (toolInput.old_string != null || toolInput.new_string != null || toolInput.edits != null) {
1641
+ diffField = {};
1642
+ if (toolInput.old_string != null) diffField.old_string = toolInput.old_string;
1643
+ if (toolInput.new_string != null) diffField.new_string = toolInput.new_string;
1644
+ if (toolInput.edits != null) diffField.edits = toolInput.edits;
1645
+ }
1646
+
1647
+ // Read file before edit for cloud payload
1648
+ let fileBefore = '';
1649
+ if (toolName !== 'Write' && filePath && existsSync(filePath)) {
1650
+ try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
1651
+ }
1652
+
1653
+ // Extract transcript context
1654
+ const transcript = extractTranscript(transcriptPath);
1655
+ const lastPrompt = readLastPrompt();
1656
+
1657
+ // Load config and decide route
1658
+ const config = await loadConfig(jwt);
1659
+ const rt = await route(config);
1660
+ const tagStr = tag(rt, config);
1661
+
1662
+ if (config.silent) {
1663
+ outputJson({ systemMessage: tagStr + ' editGuard \\u2192 skipped (silent mode)' });
1664
+ return;
1665
+ }
1666
+
1667
+ if (rt === 'local') {
1668
+ // \u2500\u2500\u2500 Local grading: org rules ONLY (channel 1, port 8929) \u2500\u2500\u2500
1669
+ const proposedShort = proposed.slice(0, 4000);
1670
+ const graderPrompt = [
1671
+ 'Working directory: ' + (cwd || '.'),
1672
+ 'Repo: ' + (gitRepo || 'unknown'),
1673
+ 'File: ' + filePath,
1674
+ 'Proposed content (first 4000 chars):',
1675
+ proposedShort,
1676
+ 'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
1677
+ 'Last user prompt: ' + (lastPrompt || 'none'),
1678
+ 'Org rules: ' + JSON.stringify(config.rules),
1679
+ ].join('\\n');
1680
+
1681
+ let gradeResp: string;
1682
+ try {
1683
+ gradeResp = await localGrade('edit', graderPrompt);
1684
+ } catch {
1685
+ outputEmpty();
1686
+ return;
1687
+ }
1688
+
1689
+ const verdict = parseVerdict(gradeResp);
1690
+ const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
1691
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
1692
+
1693
+ if (!verdict.ok) {
1694
+ const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
1695
+ const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
1696
+
1697
+ if (mode !== 'audit') {
1698
+ const denyReason = 'Guard: ' + guardReason + '\\nFix all issues before retrying.';
1699
+ dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
1700
+ toolName, gitRepo, sessionId, config.captureDepth, {
1701
+ command: editContent, reasoning: guardReason,
1702
+ rulesChecked: config.rules, violatedRules,
1703
+ ccModel: transcript.ccModel,
1704
+ });
1705
+ outputJson({
1706
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 blocked: ' + guardReason,
1707
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: denyReason },
1708
+ });
1709
+ return;
1710
+ }
1711
+
1712
+ // Audit mode \u2014 warn but allow
1713
+ dispatchCapture(jwt, 'edit', 'warning', verdict.severity || 'medium', verdict.category || 'security',
1714
+ toolName, gitRepo, sessionId, config.captureDepth, {
1715
+ command: editContent, reasoning: guardReason,
1716
+ rulesChecked: config.rules, violatedRules,
1717
+ ccModel: transcript.ccModel,
1718
+ });
1719
+ outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 warning: ' + guardReason });
1720
+ return;
1721
+ }
1722
+
1723
+ // Clean
1724
+ dispatchCapture(jwt, 'edit', 'pass', 'audit', verdict.category || 'trivial_edit',
1725
+ toolName, gitRepo, sessionId, config.captureDepth, {
1726
+ command: editContent, reasoning: verdict.reason || 'no policy violations detected',
1727
+ rulesChecked: config.rules, violatedRules: [],
1728
+ ccModel: transcript.ccModel,
1729
+ });
1730
+ outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
1731
+ return;
1732
+ }
1733
+
1734
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1735
+ const deps = findNearestDeps(filePath);
1736
+ const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
1737
+ || process.env.SYNKRO_HEADLESS === '1';
1738
+
1739
+ const body = {
1740
+ hook_event: 'PreToolUse',
1741
+ tool_name: toolName,
1742
+ tool_input: toolInput,
1743
+ file_path: filePath,
1744
+ content: proposed,
1745
+ file_before: fileBefore || null,
1746
+ diff: diffField,
1747
+ dependencies: deps,
1748
+ user_intent: transcript.userIntent || null,
1749
+ last_user_message: lastPrompt || null,
1750
+ recent_user_messages: transcript.recentUserMessages,
1751
+ recent_messages: transcript.recentMessages,
1752
+ recent_actions: transcript.recentActions,
1753
+ session_id: sessionId || null,
1754
+ tool_use_id: toolUseId || null,
1755
+ cwd: cwd || null,
1756
+ repo: gitRepo || null,
1757
+ permission_mode: permissionMode || null,
1758
+ headless: isHeadless,
1759
+ };
1760
+
1761
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
1762
+
1763
+ if (!resp) {
1764
+ log('editGuard ' + fileShort + ' \\u2192 error (timeout)');
1765
+ outputEmpty();
1766
+ return;
1767
+ }
1768
+
1769
+ if (!resp.hook_response || typeof resp.hook_response !== 'object') {
1770
+ log('editGuard ' + fileShort + ' \\u2192 pass (no hook_response)');
1771
+ outputEmpty();
1772
+ return;
1773
+ }
1774
+
1775
+ const hookResp = resp.hook_response;
1776
+ const decision = hookResp?.hookSpecificOutput?.permissionDecision;
1777
+
1778
+ if (decision === 'deny' || decision === 'ask') {
1779
+ log('editGuard ' + fileShort + ' \\u2192 BLOCKED');
1780
+ // Strip permissionDecision \u2014 we use systemMessage only
1781
+ const cleaned = { ...hookResp };
1782
+ if (cleaned.hookSpecificOutput) {
1783
+ cleaned.hookSpecificOutput = { ...cleaned.hookSpecificOutput };
1784
+ delete cleaned.hookSpecificOutput.permissionDecision;
1785
+ delete cleaned.hookSpecificOutput.permissionDecisionReason;
1786
+ }
1787
+ outputJson(cleaned);
1788
+ } else {
1789
+ const reason = hookResp.reason || '';
1790
+ log('editGuard ' + fileShort + ' \\u2192 pass' + (reason ? ': ' + reason : ''));
1791
+ outputJson(hookResp);
1792
+ }
1793
+ } catch (err) {
1794
+ process.stderr.write('[synkro] editGuard error: ' + String(err) + '\\n');
1795
+ outputEmpty();
1796
+ }
1797
+ }
1798
+
1799
+ main();
1800
+ `;
1801
+ CWE_PRECHECK_TS = `#!/usr/bin/env bun
1802
+ import {
1803
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
1804
+ localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
1805
+ outputJson, outputEmpty, GATEWAY_URL,
1806
+ } from './_synkro-common.ts';
1807
+ import { basename, extname } from 'node:path';
1808
+
1809
+ async function main() {
1810
+ try {
1811
+ const input = await readStdin();
1812
+ if (!input.trim()) { outputEmpty(); return; }
1813
+
1814
+ const payload = JSON.parse(input);
1815
+ const toolName = payload.tool_name || '';
1816
+ if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
1817
+ outputEmpty();
1818
+ return;
1819
+ }
1820
+
1821
+ const toolInput = payload.tool_input || {};
1822
+ const sessionId = payload.session_id || '';
1823
+ const cwd = payload.cwd || '';
1824
+ const gitRepo = detectRepo(cwd || '.');
1825
+
1826
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1827
+ if (!filePath) { outputEmpty(); return; }
1828
+
1829
+ const fileShort = basename(filePath);
1830
+ const fileExt = extname(filePath); // e.g. ".ts"
1831
+
1832
+ let jwt = loadJwt();
1833
+ if (!jwt) { outputEmpty(); return; }
1834
+ jwt = await ensureFreshJwt(jwt);
1835
+
1836
+ // Reconstruct proposed content
1837
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1838
+ if (!proposed) { outputEmpty(); return; }
1839
+
1840
+ const config = await loadConfig(jwt);
1841
+ const rt = await cweRoute(config);
1842
+
1843
+ if (config.silent) {
1844
+ outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1845
+ return;
1846
+ }
1847
+
1848
+ const cweTag = '[synkro:' + rt + ':cweScan]';
1849
+
1850
+ if (rt === 'local') {
1851
+ // \u2500\u2500\u2500 Local CWE grading on channel 2 (port 8930) \u2500\u2500\u2500
1852
+ let cweRules: any[] = [];
1853
+ try {
1854
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
1855
+ headers: { Authorization: 'Bearer ' + jwt },
1856
+ signal: AbortSignal.timeout(4000),
1857
+ });
1858
+ const data = await resp.json() as any;
1859
+ cweRules = data.rules || [];
1860
+ } catch {}
1861
+
1862
+ if (cweRules.length === 0) {
1863
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (no CWE rules for ' + fileExt + ')' });
1864
+ return;
1865
+ }
1866
+
1867
+ const proposedShort = proposed.slice(0, 4000);
1868
+ const graderPrompt = [
1869
+ 'File: ' + filePath,
1870
+ 'Content (first 4000 chars):',
1871
+ proposedShort,
1872
+ '',
1873
+ 'CWE rules to check against:',
1874
+ JSON.stringify(cweRules),
1875
+ ].join('\\n');
1876
+
1877
+ let gradeResp: string;
1878
+ try {
1879
+ gradeResp = await localGradeCwe(graderPrompt);
1880
+ } catch {
1881
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 grader unavailable, skipped' });
1882
+ return;
1883
+ }
1884
+
1885
+ const verdict = parseVerdict(gradeResp);
1886
+
1887
+ if (!verdict.ok) {
1888
+ // Extract all CWE rule_ids from the raw response
1889
+ const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
1890
+ const cweIds: string[] = [];
1891
+ for (const match of ruleIdMatches.slice(0, 5)) {
1892
+ const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
1893
+ if (id && !cweIds.includes(id)) cweIds.push(id);
1894
+ }
1895
+ const displayIds = cweIds.slice(0, 3).join(', ');
1896
+ const count = cweIds.length;
1897
+ const label = count === 1 ? 'match' : 'matches';
1898
+ const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
1899
+ const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
1900
+ const ctx = 'CWE: ' + denyDetail + '\\nFix all issues before retrying.';
1901
+
1902
+ outputJson({
1903
+ systemMessage: cweMsg,
1904
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
1905
+ });
1906
+ return;
1907
+ }
1908
+
1909
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
1910
+ return;
1911
+ }
1912
+
1913
+ // \u2500\u2500\u2500 Cloud CWE grading (handled by server) \u2500\u2500\u2500
1914
+ // Cloud edit precheck already includes CWE \u2014 this hook is a no-op for cloud.
1915
+ outputEmpty();
1916
+ } catch (err) {
1917
+ process.stderr.write('[synkro] cweGuard error: ' + String(err) + '\\n');
1918
+ outputEmpty();
1919
+ }
1920
+ }
1921
+
1922
+ main();
1923
+ `;
1924
+ CVE_PRECHECK_TS = `#!/usr/bin/env bun
1925
+ import {
1926
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
1927
+ reconstructContent, readStdin, findNearestDeps, log,
1928
+ outputJson, outputEmpty, GATEWAY_URL,
1929
+ } from './_synkro-common.ts';
1930
+ import { basename } from 'node:path';
1931
+
1932
+ const MANIFEST_NAMES = new Set([
1933
+ 'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
1934
+ 'Pipfile', 'go.mod', 'go.sum', 'Gemfile', 'pom.xml', 'Cargo.toml', 'composer.json', 'pyproject.toml',
1935
+ ]);
1936
+
1937
+ function isManifest(filename: string): boolean {
1938
+ if (MANIFEST_NAMES.has(filename)) return true;
1939
+ if (filename.startsWith('requirements') && filename.endsWith('.txt')) return true;
1940
+ if (filename.startsWith('build.gradle')) return true;
1941
+ if (filename.endsWith('.cabal')) return true;
1942
+ return false;
1943
+ }
1944
+
1945
+ async function main() {
1946
+ try {
1947
+ const input = await readStdin();
1948
+ if (!input.trim()) { outputEmpty(); return; }
1949
+
1950
+ const payload = JSON.parse(input);
1951
+ const toolName = payload.tool_name || '';
1952
+ if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
1953
+ outputEmpty();
1954
+ return;
1955
+ }
1956
+
1957
+ const toolInput = payload.tool_input || {};
1958
+ const cwd = payload.cwd || '';
1959
+
1960
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1961
+ if (!filePath) { outputEmpty(); return; }
1962
+
1963
+ const fileShort = basename(filePath);
1964
+
1965
+ let jwt = loadJwt();
1966
+ if (!jwt) { outputEmpty(); return; }
1967
+ jwt = await ensureFreshJwt(jwt);
1968
+
1969
+ const config = await loadConfig(jwt);
1970
+ const rt = await route(config);
1971
+
1972
+ if (config.silent) {
1973
+ outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1974
+ return;
1975
+ }
1976
+
1977
+ const cveTag = '[synkro:' + rt + ':cveScan]';
1978
+
1979
+ // Reconstruct proposed content
1980
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1981
+ if (!proposed) {
1982
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
1983
+ return;
1984
+ }
1985
+
1986
+ const proposedShort = proposed.slice(0, 4000);
1987
+
1988
+ // For code files, find nearest package.json and extract deps
1989
+ let deps: Record<string, string> = {};
1990
+ if (!isManifest(fileShort)) {
1991
+ deps = findNearestDeps(filePath);
1992
+ }
1993
+
1994
+ // CVE scan via OSV API
1995
+ const cveBody = {
1996
+ file_path: filePath,
1997
+ content: proposedShort,
1998
+ dependencies: deps,
1999
+ };
2000
+
2001
+ let cveResp: any;
2002
+ try {
2003
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
2004
+ method: 'POST',
2005
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2006
+ body: JSON.stringify(cveBody),
2007
+ signal: AbortSignal.timeout(8000),
2008
+ });
2009
+ cveResp = await resp.json();
2010
+ } catch {
2011
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 error (timeout)' });
2012
+ return;
2013
+ }
2014
+
2015
+ const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
2016
+ if (findings.length > 0) {
2017
+ const top3 = findings.slice(0, 3).map((f: any) => {
2018
+ const id = f.cve || f.id || '?';
2019
+ const pkg = f.package || '?';
2020
+ const ver = f.version || '?';
2021
+ const title = f.title || f.summary || 'vulnerable';
2022
+ return '[' + id + '] ' + pkg + '@' + ver + ': ' + title;
2023
+ }).join('; ');
2024
+
2025
+ const count = findings.length;
2026
+ const label = count === 1 ? 'advisory' : 'advisories';
2027
+ const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
2028
+ const ctx = 'CVE: ' + top3 + '\\nFix all issues before retrying.';
2029
+
2030
+ outputJson({
2031
+ systemMessage: cveMsg,
2032
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
2033
+ });
2034
+ return;
2035
+ }
2036
+
2037
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
2038
+ } catch (err) {
2039
+ process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
2040
+ outputEmpty();
2041
+ }
2042
+ }
2043
+
2044
+ main();
2045
+ `;
2046
+ BASH_JUDGE_TS = `#!/usr/bin/env bun
2047
+ import {
2048
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
2049
+ parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
2050
+ extractTranscript, readLastPrompt, log,
2051
+ outputJson, outputEmpty, GATEWAY_URL,
2052
+ type HookConfig, type Rule,
2053
+ } from './_synkro-common.ts';
2054
+
2055
+ async function main() {
2056
+ try {
2057
+ const input = await readStdin();
2058
+ if (!input.trim()) { outputEmpty(); return; }
2059
+
2060
+ const payload = JSON.parse(input);
2061
+ const toolName = payload.tool_name || '';
2062
+ if (!['Bash', 'Read', 'Grep', 'Glob'].includes(toolName)) {
2063
+ outputEmpty();
2064
+ return;
2065
+ }
2066
+
2067
+ const toolInput = payload.tool_input || {};
2068
+ const sessionId = payload.session_id || '';
2069
+ const toolUseId = payload.tool_use_id || '';
2070
+ const cwd = payload.cwd || '';
2071
+ const permissionMode = payload.permission_mode || '';
2072
+ const transcriptPath = payload.transcript_path || '';
2073
+ const gitRepo = detectRepo(cwd || '.');
2074
+
2075
+ let command = '';
2076
+ switch (toolName) {
2077
+ case 'Bash': command = toolInput.command || ''; break;
2078
+ case 'Read': command = 'cat ' + (toolInput.file_path || ''); break;
2079
+ case 'Grep': command = "grep -r '" + (toolInput.pattern || '') + "' " + (toolInput.path || '.'); break;
2080
+ case 'Glob': command = "find . -name '" + (toolInput.pattern || '') + "'"; break;
2081
+ }
2082
+ if (!command) { outputEmpty(); return; }
2083
+
2084
+ const cmdShort = command.slice(0, 80);
2085
+ log('bashGuard checking: ' + cmdShort);
2086
+
2087
+ let jwt = loadJwt();
2088
+ if (!jwt) { outputEmpty(); return; }
2089
+ jwt = await ensureFreshJwt(jwt);
2090
+
2091
+ const transcript = extractTranscript(transcriptPath);
2092
+ const lastPrompt = readLastPrompt();
2093
+
2094
+ const config = await loadConfig(jwt);
2095
+ const rt = await route(config);
2096
+ const tagStr = tag(rt, config);
2097
+
2098
+ if (config.silent) {
2099
+ outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 skipped (silent mode)' });
2100
+ return;
2101
+ }
2102
+
2103
+ if (rt === 'local') {
2104
+ const graderPrompt = [
2105
+ 'Working directory: ' + (cwd || '.'),
2106
+ 'Repo: ' + (gitRepo || 'unknown'),
2107
+ 'Command: ' + command,
2108
+ 'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
2109
+ 'Last user prompt: ' + (lastPrompt || 'none'),
2110
+ 'Org rules: ' + JSON.stringify(config.rules),
2111
+ ].join('\\n');
2112
+
2113
+ let gradeResp: string;
2114
+ try {
2115
+ gradeResp = await localGrade('bash', graderPrompt);
2116
+ } catch {
2117
+ outputEmpty();
2118
+ return;
2119
+ }
2120
+
2121
+ const verdict = parseVerdict(gradeResp);
2122
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
2123
+
2124
+ if (!verdict.ok) {
2125
+ const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
2126
+ const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
2127
+
2128
+ if (mode === 'audit') {
2129
+ const reason = tagStr + ' bashGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
2130
+ outputJson({ systemMessage: reason });
2131
+ dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
2132
+ toolName, gitRepo, sessionId, config.captureDepth, {
2133
+ command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
2134
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2135
+ });
2136
+ } else {
2137
+ const reason = tagStr + ' bashGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
2138
+ outputJson({
2139
+ systemMessage: reason,
2140
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
2141
+ });
2142
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
2143
+ toolName, gitRepo, sessionId, config.captureDepth, {
2144
+ command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
2145
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2146
+ });
2147
+ }
2148
+ } else {
2149
+ outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
2150
+ dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
2151
+ toolName, gitRepo, sessionId, config.captureDepth, {
2152
+ command, reasoning: verdict.reason || 'no policy violations detected',
2153
+ rulesChecked: config.rules, violatedRules: [],
2154
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2155
+ });
2156
+ }
2157
+ return;
2158
+ }
2159
+
2160
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
2161
+ const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
2162
+ || process.env.SYNKRO_HEADLESS === '1';
2163
+
2164
+ const body: Record<string, any> = {
2165
+ hook_event: 'PreToolUse',
2166
+ tool_name: toolName,
2167
+ tool_input: toolInput,
2168
+ user_intent: transcript.userIntent || null,
2169
+ last_user_message: lastPrompt || null,
2170
+ recent_user_messages: transcript.recentUserMessages,
2171
+ recent_messages: transcript.recentMessages,
2172
+ recent_actions: transcript.recentActions,
2173
+ session_id: sessionId || null,
2174
+ tool_use_id: toolUseId || null,
2175
+ cwd: cwd || null,
2176
+ repo: gitRepo || null,
2177
+ permission_mode: permissionMode || null,
2178
+ headless: isHeadless,
2179
+ cc_model: transcript.ccModel || null,
2180
+ cc_usage: transcript.ccUsage || {},
2181
+ session_summary: transcript.sessionSummary || null,
2182
+ };
2183
+
2184
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
2185
+
2186
+ if (!resp) {
2187
+ log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
2188
+ outputEmpty();
2189
+ return;
2190
+ }
2191
+
2192
+ if (!resp.hook_response || typeof resp.hook_response !== 'object') {
2193
+ log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
2194
+ outputEmpty();
2195
+ return;
2196
+ }
2197
+
2198
+ outputJson(resp.hook_response);
2199
+ } catch (err) {
2200
+ process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
2201
+ outputEmpty();
2202
+ }
2203
+ }
2204
+
2205
+ main();
2206
+ `;
2207
+ PLAN_JUDGE_TS = `#!/usr/bin/env bun
2208
+ import {
2209
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
2210
+ parseVerdict, dispatchCapture, postWithRetry, readStdin, log,
2211
+ outputJson, outputEmpty, GATEWAY_URL,
2212
+ } from './_synkro-common.ts';
2213
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
2214
+ import { join } from 'node:path';
2215
+ import { homedir } from 'node:os';
2216
+
2217
+ function findLatestPlan(): string | null {
2218
+ const plansDir = join(homedir(), '.claude', 'plans');
2219
+ if (!existsSync(plansDir)) return null;
2220
+ try {
2221
+ const files = readdirSync(plansDir)
2222
+ .filter(f => f.endsWith('.md'))
2223
+ .map(f => ({ name: f, mtime: statSync(join(plansDir, f)).mtimeMs }))
2224
+ .sort((a, b) => b.mtime - a.mtime);
2225
+ return files.length > 0 ? join(plansDir, files[0].name) : null;
2226
+ } catch {
2227
+ return null;
2228
+ }
2229
+ }
2230
+
2231
+ function appendReviewToPlan(planFile: string, verdict: string): void {
2232
+ try {
2233
+ let content = readFileSync(planFile, 'utf-8');
2234
+ content = content.replace(/<!-- synkro-plan-review -->[\\s\\S]*?<!-- \\/synkro-plan-review -->/g, '').trimEnd();
2235
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
2236
+ content += '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \\u2014 ' + now + '\\n\\n' + verdict + '\\n\\n<!-- /synkro-plan-review -->\\n';
2237
+ writeFileSync(planFile, content, 'utf-8');
2238
+ } catch {}
2239
+ }
2240
+
2241
+ async function main() {
2242
+ try {
2243
+ const input = await readStdin();
2244
+ if (!input.trim()) { outputEmpty(); return; }
2245
+
2246
+ const payload = JSON.parse(input);
2247
+ const toolName = payload.tool_name || '';
2248
+ if (toolName !== 'ExitPlanMode') { outputEmpty(); return; }
2249
+
2250
+ const planFile = findLatestPlan();
2251
+ if (!planFile) { outputEmpty(); return; }
2252
+ const plan = readFileSync(planFile, 'utf-8');
2253
+ if (plan.length < 20) { outputEmpty(); return; }
2254
+
2255
+ const sessionId = payload.session_id || '';
2256
+ const cwd = payload.cwd || '';
2257
+ const gitRepo = detectRepo(cwd || '.');
2258
+
2259
+ const planShort = plan.slice(0, 80);
2260
+ log('planReview checking: ' + planShort + '...');
2261
+
2262
+ let jwt = loadJwt();
2263
+ if (!jwt) { outputEmpty(); return; }
2264
+ jwt = await ensureFreshJwt(jwt);
2265
+
2266
+ const config = await loadConfig(jwt);
2267
+ const rt = await route(config);
2268
+ const tagStr = tag(rt, config);
2269
+
2270
+ if (config.silent) {
2271
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
2272
+ return;
2273
+ }
2274
+
2275
+ if (rt === 'local') {
2276
+ const graderPrompt = [
2277
+ 'Working directory: ' + (cwd || '.'),
2278
+ 'Repo: ' + (gitRepo || 'unknown'),
2279
+ 'Plan:',
2280
+ plan.slice(0, 8000),
2281
+ 'Org rules: ' + JSON.stringify(config.rules),
2282
+ ].join('\\n');
2283
+
2284
+ let gradeResp: string;
2285
+ try {
2286
+ gradeResp = await localGrade('plan', graderPrompt);
2287
+ } catch {
2288
+ outputEmpty();
2289
+ return;
2290
+ }
2291
+
2292
+ const verdict = parseVerdict(gradeResp);
2293
+ const planContent = plan.slice(0, 2000);
2294
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
2295
+
2296
+ if (!verdict.ok) {
2297
+ const reviewMsg = (verdict.ruleId ? '(first: ' + verdict.ruleId + ') ' : '') + (verdict.reason || 'check org rules during implementation');
2298
+ appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reviewMsg);
2299
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 ' + reviewMsg });
2300
+ dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
2301
+ 'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
2302
+ command: planContent, reasoning: verdict.reason || 'check org rules',
2303
+ rulesChecked: config.rules, violatedRules,
2304
+ });
2305
+ } else {
2306
+ const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
2307
+ appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
2308
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 clean: ' + reviewMsg });
2309
+ dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
2310
+ 'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
2311
+ command: planContent, reasoning: reviewMsg,
2312
+ rulesChecked: config.rules, violatedRules: [],
2313
+ });
2314
+ }
2315
+ return;
2316
+ }
2317
+
2318
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
2319
+ const body = {
2320
+ hook_event: 'PreToolUse',
2321
+ tool_name: 'ExitPlanMode',
2322
+ tool_input: { plan: plan.slice(0, 16000) },
2323
+ session_id: sessionId || null,
2324
+ cwd: cwd || null,
2325
+ repo: gitRepo || null,
2326
+ };
2327
+
2328
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
2329
+
2330
+ if (!resp) {
2331
+ log('planReview \\u2192 error (timeout)');
2332
+ outputEmpty();
2333
+ return;
2334
+ }
2335
+
2336
+ const hookResp = resp?.hook_response;
2337
+ if (!hookResp) { outputEmpty(); return; }
2338
+
2339
+ const decision = hookResp?.hookSpecificOutput?.permissionDecision;
2340
+ if (decision) {
2341
+ const reason = hookResp?.hookSpecificOutput?.permissionDecisionReason || 'check org rules';
2342
+ appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reason);
2343
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 advisory: ' + reason });
2344
+ } else {
2345
+ const cloudMsg = hookResp.systemMessage || '';
2346
+ if (cloudMsg) appendReviewToPlan(planFile, '\\u2705 ' + cloudMsg);
2347
+ outputJson(hookResp);
2348
+ }
2349
+ } catch (err) {
2350
+ process.stderr.write('[synkro] planReview error: ' + String(err) + '\\n');
2351
+ outputEmpty();
2352
+ }
2353
+ }
1872
2354
 
1873
- BODY=$(jq -n --arg repo "$GIT_REPO" --arg sid "$SESSION_ID" --argjson messages "$MESSAGES" \\
1874
- '{repo: $repo, sessions: [{cc_session_id: $sid, messages: $messages}]}')
2355
+ main();
2356
+ `;
2357
+ STOP_SUMMARY_TS = `#!/usr/bin/env bun
2358
+ import {
2359
+ loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
2360
+ outputJson, outputEmpty, GATEWAY_URL,
2361
+ } from './_synkro-common.ts';
1875
2362
 
1876
- (
1877
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/cli/sync-transcripts" \\
1878
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1879
- -d "$BODY" --max-time 10 >/dev/null 2>&1
1880
- ) &
1881
- disown 2>/dev/null || true
2363
+ async function main() {
2364
+ try {
2365
+ const input = await readStdin();
2366
+ if (!input.trim()) { outputEmpty(); return; }
2367
+
2368
+ const payload = JSON.parse(input);
2369
+ const sessionId = payload.session_id || '';
2370
+ if (!sessionId) { outputEmpty(); return; }
2371
+
2372
+ const cwd = payload.cwd || '';
2373
+ const transcriptPath = payload.transcript_path || '';
2374
+ const gitRepo = detectRepo(cwd || '.');
2375
+
2376
+ let jwt = loadJwt();
2377
+ if (!jwt) { outputEmpty(); return; }
2378
+
2379
+ if (transcriptPath) {
2380
+ const usage = aggregateUsage(transcriptPath);
2381
+ if (usage.totals.in + usage.totals.out > 0) {
2382
+ const usageBody = {
2383
+ capture_type: 'local_verdict',
2384
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
2385
+ hook_type: 'stop',
2386
+ verdict: 'allow',
2387
+ severity: 'none',
2388
+ model: usage.model || 'unknown',
2389
+ cc_model: usage.model || '',
2390
+ cc_usage: {
2391
+ input_tokens: usage.totals.in,
2392
+ output_tokens: usage.totals.out,
2393
+ cache_creation_input_tokens: usage.totals.cw,
2394
+ cache_read_input_tokens: usage.totals.cr,
2395
+ },
2396
+ ...(gitRepo ? { repo: gitRepo } : {}),
2397
+ ...(sessionId ? { session_id: sessionId } : {}),
2398
+ };
2399
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2400
+ method: 'POST',
2401
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2402
+ body: JSON.stringify(usageBody),
2403
+ signal: AbortSignal.timeout(3000),
2404
+ }).catch(() => {});
2405
+ }
2406
+ }
2407
+
2408
+ let resp: any;
2409
+ try {
2410
+ const r = await fetch(GATEWAY_URL + '/api/v1/cli/session-summary?session_id=' + encodeURIComponent(sessionId), {
2411
+ headers: { Authorization: 'Bearer ' + jwt },
2412
+ signal: AbortSignal.timeout(3000),
2413
+ });
2414
+ resp = await r.json();
2415
+ } catch {
2416
+ outputEmpty();
2417
+ return;
2418
+ }
2419
+
2420
+ const edits = resp?.edits_scanned || 0;
2421
+ const findings = resp?.findings || 0;
2422
+ const autoFixed = resp?.auto_fixed || 0;
2423
+ const open = resp?.open || 0;
2424
+
2425
+ if (!edits) { outputEmpty(); return; }
2426
+
2427
+ const config = await loadConfig(jwt);
2428
+ const tagStr = tag('local', config);
1882
2429
 
1883
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
1884
- echo '{}'; exit 0
2430
+ if (!findings) {
2431
+ outputJson({ systemMessage: tagStr + ' stop \\u2192 0 issues across ' + edits + ' edit(s), session complete' });
2432
+ } else {
2433
+ outputJson({ systemMessage: tagStr + ' stop \\u2192 ' + findings + ' finding(s): ' + autoFixed + ' auto-fixed, ' + open + ' open' });
2434
+ }
2435
+ } catch (err) {
2436
+ process.stderr.write('[synkro] stopSummary error: ' + String(err) + '\\n');
2437
+ outputEmpty();
2438
+ }
2439
+ }
2440
+
2441
+ main();
1885
2442
  `;
1886
- CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
1887
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1888
- . "$SCRIPT_DIR/_synkro-common.sh"
2443
+ SESSION_START_TS = `#!/usr/bin/env bun
2444
+ import {
2445
+ loadJwt, detectRepo, channelUp, tag, readStdin,
2446
+ outputJson, outputEmpty, GATEWAY_URL,
2447
+ type HookConfig,
2448
+ } from './_synkro-common.ts';
1889
2449
 
1890
- JWT=$(synkro_load_jwt)
1891
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1892
- synkro_ensure_fresh_jwt
2450
+ async function main() {
2451
+ try {
2452
+ const input = await readStdin();
2453
+ if (!input.trim()) { outputEmpty(); return; }
1893
2454
 
1894
- PAYLOAD=$(cat)
1895
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2455
+ const payload = JSON.parse(input);
2456
+ const cwd = payload.cwd || '';
2457
+ const sessionId = payload.session_id || '';
2458
+ const gitRepo = detectRepo(cwd || '.');
1896
2459
 
1897
- COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
1898
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
2460
+ let jwt = loadJwt();
1899
2461
 
1900
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1901
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1902
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2462
+ const isChannelUp = await channelUp();
2463
+ const rt = isChannelUp ? 'local' : 'cloud';
1903
2464
 
1904
- CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
1905
- synkro_log "bashGuard checking: $CMD_SHORT"
2465
+ let policyName = '';
2466
+ let silent = false;
2467
+ let openFindings = 0;
1906
2468
 
1907
- synkro_load_config
1908
- if [ "$SYNKRO_SILENT" = "true" ]; then
1909
- echo '{}'; exit 0
1910
- fi
2469
+ if (jwt) {
2470
+ try {
2471
+ const url = GATEWAY_URL + '/api/v1/hook/config?session_id=' + encodeURIComponent(sessionId || '') + '&repo=' + encodeURIComponent(gitRepo || '');
2472
+ const r = await fetch(url, {
2473
+ headers: { Authorization: 'Bearer ' + jwt },
2474
+ signal: AbortSignal.timeout(3000),
2475
+ });
2476
+ const data = await r.json() as any;
2477
+ silent = data.silent_mode === true || data.silent_mode === 'true';
2478
+ policyName = data.active_policy_name || '';
2479
+ openFindings = data.session_context?.open_findings || 0;
2480
+ } catch {}
2481
+ }
1911
2482
 
1912
- BODY=$(jq -n \\
1913
- --arg cmd "$COMMAND" \\
1914
- --arg session_id "$SESSION_ID" \\
1915
- --arg cwd "$CWD" \\
1916
- --arg repo "$GIT_REPO" \\
1917
- '{
1918
- hook_event: "PreToolUse",
1919
- tool_name: "Bash",
1920
- tool_input: {command: $cmd},
1921
- response_format: "cursor",
1922
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1923
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1924
- repo: (if ($repo | length) > 0 then $repo else null end)
1925
- }')
2483
+ const fakeConfig: HookConfig = { captureDepth: 'local_only', tier: 'standard', silent, policyName, rules: [] };
2484
+ const tagStr = tag(rt, fakeConfig);
2485
+ const routeLine = tagStr + ' inference: ' + (isChannelUp ? 'local-cc (channel reachable on 127.0.0.1:8929)' : 'cloud (local-cc channel not reachable)');
1926
2486
 
1927
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
2487
+ if (!jwt) {
2488
+ outputJson({ systemMessage: routeLine });
2489
+ return;
2490
+ }
1928
2491
 
1929
- if [ -z "$RESP" ]; then
1930
- synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
1931
- echo '{}'; exit 0
1932
- fi
2492
+ if (!openFindings) {
2493
+ outputJson({ systemMessage: routeLine });
2494
+ } else if (openFindings === 1) {
2495
+ outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 1 open finding in this repo from a prior session.' });
2496
+ } else {
2497
+ outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 ' + openFindings + ' open findings in this repo from prior sessions.' });
2498
+ }
2499
+ } catch (err) {
2500
+ process.stderr.write('[synkro] sessionStart error: ' + String(err) + '\\n');
2501
+ outputEmpty();
2502
+ }
2503
+ }
1933
2504
 
1934
- # Server returns cursor-format directly in hook_response
1935
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1936
- echo "$RESP" | jq -c '.hook_response'
1937
- else
1938
- echo '{}'
1939
- fi
1940
- exit 0
2505
+ main();
1941
2506
  `;
1942
- CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
1943
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1944
- . "$SCRIPT_DIR/_synkro-common.sh"
2507
+ BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2508
+ import {
2509
+ loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2510
+ outputEmpty, GATEWAY_URL,
2511
+ } from './_synkro-common.ts';
1945
2512
 
1946
- JWT=$(synkro_load_jwt)
1947
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1948
- synkro_ensure_fresh_jwt
2513
+ async function main() {
2514
+ try {
2515
+ const input = await readStdin();
2516
+ if (!input.trim()) { outputEmpty(); return; }
1949
2517
 
1950
- PAYLOAD=$(cat)
1951
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2518
+ const payload = JSON.parse(input);
2519
+ const toolName = payload.tool_name || '';
2520
+ if (toolName !== 'Bash') { outputEmpty(); return; }
1952
2521
 
1953
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1954
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1955
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1956
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2522
+ const jwt = loadJwt();
2523
+ if (!jwt) { outputEmpty(); return; }
1957
2524
 
1958
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
1959
- CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
1960
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2525
+ const sessionId = payload.session_id || '';
2526
+ const toolUseId = payload.tool_use_id || '';
2527
+ if (!sessionId || !toolUseId) { outputEmpty(); return; }
1961
2528
 
1962
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
1963
- synkro_log "editGuard checking: $BASENAME"
2529
+ const isError = payload.tool_result?.is_error === true;
2530
+ const cmd = payload.tool_input?.command || '';
2531
+ const cmdHash = cmd ? hashCommand(cmd) : '';
1964
2532
 
1965
- synkro_load_config
1966
- if [ "$SYNKRO_SILENT" = "true" ]; then
1967
- echo '{}'; exit 0
1968
- fi
2533
+ if (cmdHash && sessionId) {
2534
+ if (!isError) {
2535
+ consentConsume(sessionId, cmdHash);
2536
+ } else {
2537
+ if (!consentHasActive(sessionId, cmdHash)) {
2538
+ consentGrant(sessionId, cmdHash);
2539
+ }
2540
+ }
2541
+ }
1969
2542
 
1970
- BODY=$(jq -n \\
1971
- --arg file_path "$FILE_PATH" \\
1972
- --arg content "$CONTENT" \\
1973
- --arg session_id "$SESSION_ID" \\
1974
- --arg cwd "$CWD" \\
1975
- --arg repo "$GIT_REPO" \\
1976
- '{
1977
- hook_event: "PreToolUse",
1978
- tool_name: "Edit",
1979
- tool_input: {file_path: $file_path, content: $content},
1980
- file_path: $file_path,
1981
- content: $content,
1982
- response_format: "cursor",
1983
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1984
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1985
- repo: (if ($repo | length) > 0 then $repo else null end)
1986
- }')
2543
+ const body = {
2544
+ capture_type: 'bash_followup',
2545
+ session_id: sessionId,
2546
+ tool_use_id: toolUseId,
2547
+ is_error: isError,
2548
+ command_hash: cmdHash,
2549
+ };
1987
2550
 
1988
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
2551
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2552
+ method: 'POST',
2553
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2554
+ body: JSON.stringify(body),
2555
+ signal: AbortSignal.timeout(3000),
2556
+ }).catch(() => {});
1989
2557
 
1990
- if [ -z "$RESP" ]; then
1991
- synkro_log "editGuard $BASENAME \u2192 error (timeout)"
1992
- echo '{}'; exit 0
1993
- fi
2558
+ outputEmpty();
2559
+ } catch {
2560
+ outputEmpty();
2561
+ }
2562
+ }
1994
2563
 
1995
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1996
- echo "$RESP" | jq -c '.hook_response'
1997
- else
1998
- echo '{}'
1999
- fi
2000
- exit 0
2564
+ main();
2001
2565
  `;
2002
- CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
2003
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2004
- . "$SCRIPT_DIR/_synkro-common.sh"
2566
+ TRANSCRIPT_SYNC_TS = `#!/usr/bin/env bun
2567
+ import {
2568
+ loadJwt, detectRepo, readStdin, aggregateUsage,
2569
+ outputEmpty, GATEWAY_URL,
2570
+ } from './_synkro-common.ts';
2571
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2572
+ import { join, dirname } from 'node:path';
2573
+ import { homedir } from 'node:os';
2005
2574
 
2006
- JWT=$(synkro_load_jwt)
2007
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2575
+ async function main() {
2576
+ try {
2577
+ const input = await readStdin();
2578
+ if (!input.trim()) { outputEmpty(); return; }
2008
2579
 
2009
- PAYLOAD=$(cat)
2010
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2580
+ const payload = JSON.parse(input);
2581
+ const sessionId = payload.session_id || '';
2582
+ const transcriptPath = payload.transcript_path || '';
2583
+ const cwd = payload.cwd || '';
2011
2584
 
2012
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
2013
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2585
+ if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
2586
+ outputEmpty();
2587
+ return;
2588
+ }
2014
2589
 
2015
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
2016
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2017
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2018
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
2590
+ const jwt = loadJwt();
2591
+ if (!jwt) { outputEmpty(); return; }
2592
+
2593
+ const usage = aggregateUsage(transcriptPath);
2594
+ if (usage.totals.in + usage.totals.out > 0) {
2595
+ const usageBody = {
2596
+ capture_type: 'usage_tick',
2597
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
2598
+ hook_type: 'stop',
2599
+ verdict: 'allow',
2600
+ severity: 'none',
2601
+ model: usage.model || 'unknown',
2602
+ cc_model: usage.model || '',
2603
+ cc_usage: {
2604
+ input_tokens: usage.totals.in,
2605
+ output_tokens: usage.totals.out,
2606
+ cache_creation_input_tokens: usage.totals.cw,
2607
+ cache_read_input_tokens: usage.totals.cr,
2608
+ },
2609
+ session_id: sessionId,
2610
+ };
2611
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2612
+ method: 'POST',
2613
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2614
+ body: JSON.stringify(usageBody),
2615
+ signal: AbortSignal.timeout(3000),
2616
+ }).catch(() => {});
2617
+ }
2019
2618
 
2020
- FULL_PATH="$FILE_PATH"
2021
- [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
2022
- FULL_CONTENT=""
2023
- [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
2619
+ if (process.env.SYNKRO_TRANSCRIPT_CONSENT === 'no') { outputEmpty(); return; }
2024
2620
 
2025
- DEPS_JSON="{}"
2026
- _PKG_DIR="\${CWD:-.}"
2027
- while [ "$_PKG_DIR" != "/" ]; do
2028
- if [ -f "$_PKG_DIR/package.json" ]; then
2029
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
2030
- break
2031
- fi
2032
- _PKG_DIR=$(dirname "$_PKG_DIR")
2033
- done
2621
+ const gitRepo = detectRepo(cwd || '.');
2622
+ if (!gitRepo) { outputEmpty(); return; }
2034
2623
 
2035
- synkro_log "editScan $BASENAME"
2624
+ let captureDepth = 'local_only';
2625
+ try {
2626
+ const r = await fetch(GATEWAY_URL + '/api/v1/hook/config', {
2627
+ headers: { Authorization: 'Bearer ' + jwt },
2628
+ signal: AbortSignal.timeout(3000),
2629
+ });
2630
+ const data = await r.json() as any;
2631
+ captureDepth = data.capture_depth || 'local_only';
2632
+ } catch {}
2633
+
2634
+ if (captureDepth === 'local_only') { outputEmpty(); return; }
2635
+
2636
+ const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
2637
+ mkdirSync(offsetDir, { recursive: true });
2638
+ const offsetFile = join(offsetDir, sessionId);
2639
+ let offset = 0;
2640
+ if (existsSync(offsetFile)) {
2641
+ try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
2642
+ }
2036
2643
 
2037
- (
2038
- BODY=$(jq -n \\
2039
- --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
2040
- --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
2041
- --argjson deps "$DEPS_JSON" \\
2042
- '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
2043
- + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
2044
- + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
2045
- + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
2046
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
2047
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
2048
- -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
2049
- ) &
2050
- disown 2>/dev/null || true
2644
+ const raw = readFileSync(transcriptPath, 'utf-8');
2645
+ const allLines = raw.split('\\n').filter(l => l.trim());
2646
+ const totalLines = allLines.length;
2051
2647
 
2052
- echo '{}'
2053
- exit 0
2054
- `;
2055
- CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
2056
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2057
- . "$SCRIPT_DIR/_synkro-common.sh"
2648
+ if (totalLines <= offset) { outputEmpty(); return; }
2058
2649
 
2059
- JWT=$(synkro_load_jwt)
2060
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2650
+ let startIdx = offset;
2651
+ const delta = totalLines - offset;
2652
+ if (delta > 200) startIdx = totalLines - 200;
2061
2653
 
2062
- PAYLOAD=$(cat)
2063
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
2064
- case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
2654
+ const messages: any[] = [];
2655
+ for (let i = startIdx; i < totalLines; i++) {
2656
+ try {
2657
+ const entry = JSON.parse(allLines[i]);
2658
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
2659
+ const content = entry.message?.content;
2660
+ let text = '';
2661
+ if (typeof content === 'string') text = content.slice(0, 8000);
2662
+ else if (Array.isArray(content)) {
2663
+ text = content.map((c: any) => {
2664
+ if (typeof c === 'string') return c;
2665
+ if (c?.type === 'text') return c.text || '';
2666
+ return '';
2667
+ }).join(' ').slice(0, 8000);
2668
+ }
2065
2669
 
2066
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2067
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
2670
+ const msg: any = { message_index: i, type: entry.type, content: text };
2671
+ if (entry.type === 'assistant') {
2672
+ const toolCalls = (Array.isArray(content) ? content : [])
2673
+ .filter((c: any) => c?.type === 'tool_use')
2674
+ .map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
2675
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
2676
+ msg.model = entry.message?.model || null;
2677
+ const u = entry.message?.usage;
2678
+ if (u) msg.usage = { input_tokens: u.input_tokens, output_tokens: u.output_tokens, cache_creation_input_tokens: u.cache_creation_input_tokens, cache_read_input_tokens: u.cache_read_input_tokens };
2679
+ }
2680
+ messages.push(msg);
2681
+ } catch {}
2682
+ }
2068
2683
 
2069
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
2070
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
2071
- CMD_HASH=""
2072
- if [ -n "$CMD" ]; then
2073
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
2074
- fi
2684
+ writeFileSync(offsetFile, String(totalLines), 'utf-8');
2075
2685
 
2076
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
2077
- if [ "$IS_ERROR" = "false" ]; then
2078
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
2079
- else
2080
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
2081
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
2082
- fi
2083
- fi
2084
- fi
2686
+ if (messages.length === 0) { outputEmpty(); return; }
2085
2687
 
2086
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
2087
- (
2088
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
2089
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
2090
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
2091
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
2092
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
2093
- -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
2094
- ) &
2095
- disown 2>/dev/null || true
2096
- fi
2688
+ const syncBody = {
2689
+ repo: gitRepo,
2690
+ sessions: [{ cc_session_id: sessionId, messages }],
2691
+ };
2692
+ fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
2693
+ method: 'POST',
2694
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2695
+ body: JSON.stringify(syncBody),
2696
+ signal: AbortSignal.timeout(10000),
2697
+ }).catch(() => {});
2698
+
2699
+ outputEmpty();
2700
+ } catch {
2701
+ outputEmpty();
2702
+ }
2703
+ }
2097
2704
 
2098
- echo '{}'
2099
- exit 0
2705
+ main();
2100
2706
  `;
2101
- CC_USER_PROMPT_SUBMIT_SCRIPT = `#!/bin/bash
2102
- # Synkro UserPromptSubmit hook \u2014 stashes the user's last message so PreToolUse
2103
- # hooks can pass it to the grader for consent detection. No regex matching here;
2104
- # the grader (local or cloud) decides whether the message constitutes consent.
2105
- PROMPT_FILE="$HOME/.synkro/.last-prompt"
2106
- PAYLOAD=$(cat)
2107
- if [ -z "$PAYLOAD" ]; then exit 0; fi
2108
- MSG=$(echo "$PAYLOAD" | jq -r '.message // .prompt // .content // empty' 2>/dev/null)
2109
- if [ -n "$MSG" ]; then
2110
- printf '%s' "$MSG" > "$PROMPT_FILE" 2>/dev/null || true
2111
- fi
2112
- exit 0
2707
+ USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
2708
+ import { readStdin } from './_synkro-common.ts';
2709
+ import { writeFileSync, mkdirSync } from 'node:fs';
2710
+ import { join, dirname } from 'node:path';
2711
+ import { homedir } from 'node:os';
2712
+
2713
+ async function main() {
2714
+ try {
2715
+ const input = await readStdin();
2716
+ if (!input.trim()) return;
2717
+ const payload = JSON.parse(input);
2718
+ const msg = payload.message || payload.prompt || payload.content || '';
2719
+ if (msg) {
2720
+ const promptFile = join(homedir(), '.synkro', '.last-prompt');
2721
+ mkdirSync(dirname(promptFile), { recursive: true });
2722
+ writeFileSync(promptFile, msg, 'utf-8');
2723
+ }
2724
+ } catch {}
2725
+ }
2726
+
2727
+ main();
2113
2728
  `;
2114
2729
  }
2115
2730
  });
@@ -4322,34 +4937,36 @@ function ensureSynkroDir() {
4322
4937
  mkdirSync8(OFFSETS_DIR, { recursive: true });
4323
4938
  }
4324
4939
  function writeHookScripts() {
4325
- const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.sh");
4326
- const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.sh");
4940
+ const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
4941
+ const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
4327
4942
  const editCaptureScriptPath = join11(HOOKS_DIR, "cc-edit-capture.sh");
4328
- const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.sh");
4329
- const cveScanScriptPath = join11(HOOKS_DIR, "cc-cve-scan.sh");
4330
- const cweScanScriptPath = join11(HOOKS_DIR, "cc-cwe-scan.sh");
4331
- const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.sh");
4332
- const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.sh");
4333
- const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.sh");
4334
- const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.sh");
4335
- const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.sh");
4336
- const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
4943
+ const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
4944
+ const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
4945
+ const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
4946
+ const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
4947
+ const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
4948
+ const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
4949
+ const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
4950
+ const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
4951
+ const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
4952
+ const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
4337
4953
  const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
4338
4954
  const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
4339
4955
  const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
4340
4956
  const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
4341
- writeFileSync7(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
4342
- writeFileSync7(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
4343
- writeFileSync7(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
4344
- writeFileSync7(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
4345
- writeFileSync7(cveScanScriptPath, CC_CVE_SCAN_SCRIPT, "utf-8");
4346
- writeFileSync7(cweScanScriptPath, CC_CWE_SCAN_SCRIPT, "utf-8");
4347
- writeFileSync7(planJudgeScriptPath, CC_PLAN_JUDGE_SCRIPT, "utf-8");
4348
- writeFileSync7(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
4349
- writeFileSync7(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
4350
- writeFileSync7(transcriptSyncScriptPath, CC_TRANSCRIPT_SYNC_SCRIPT, "utf-8");
4351
- writeFileSync7(userPromptSubmitScriptPath, CC_USER_PROMPT_SUBMIT_SCRIPT, "utf-8");
4352
- writeFileSync7(commonScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
4957
+ writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
4958
+ writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
4959
+ writeFileSync7(editCaptureScriptPath, "", "utf-8");
4960
+ writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
4961
+ writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
4962
+ writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
4963
+ writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
4964
+ writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
4965
+ writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
4966
+ writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
4967
+ writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
4968
+ writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
4969
+ writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
4353
4970
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
4354
4971
  writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
4355
4972
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
@@ -4358,14 +4975,15 @@ function writeHookScripts() {
4358
4975
  chmodSync2(bashFollowupScriptPath, 493);
4359
4976
  chmodSync2(editCaptureScriptPath, 493);
4360
4977
  chmodSync2(editPrecheckScriptPath, 493);
4361
- chmodSync2(cveScanScriptPath, 493);
4362
- chmodSync2(cweScanScriptPath, 493);
4978
+ chmodSync2(cwePrecheckScriptPath, 493);
4979
+ chmodSync2(cvePrecheckScriptPath, 493);
4363
4980
  chmodSync2(planJudgeScriptPath, 493);
4364
4981
  chmodSync2(stopSummaryScriptPath, 493);
4365
4982
  chmodSync2(sessionStartScriptPath, 493);
4366
4983
  chmodSync2(transcriptSyncScriptPath, 493);
4367
4984
  chmodSync2(userPromptSubmitScriptPath, 493);
4368
4985
  chmodSync2(commonScriptPath, 493);
4986
+ chmodSync2(commonBashScriptPath, 493);
4369
4987
  chmodSync2(cursorBashJudgePath, 493);
4370
4988
  chmodSync2(cursorEditPrecheckPath, 493);
4371
4989
  chmodSync2(cursorEditCapturePath, 493);
@@ -4375,8 +4993,8 @@ function writeHookScripts() {
4375
4993
  bashFollowupScript: bashFollowupScriptPath,
4376
4994
  editCaptureScript: editCaptureScriptPath,
4377
4995
  editPrecheckScript: editPrecheckScriptPath,
4378
- cveScanScript: cveScanScriptPath,
4379
- cweScanScript: cweScanScriptPath,
4996
+ cwePrecheckScript: cwePrecheckScriptPath,
4997
+ cvePrecheckScript: cvePrecheckScriptPath,
4380
4998
  planJudgeScript: planJudgeScriptPath,
4381
4999
  stopSummaryScript: stopSummaryScriptPath,
4382
5000
  sessionStartScript: sessionStartScriptPath,
@@ -4417,7 +5035,7 @@ function writeConfigEnv(opts) {
4417
5035
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
4418
5036
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
4419
5037
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
4420
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.45")}`
5038
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.47")}`
4421
5039
  ];
4422
5040
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
4423
5041
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -4725,8 +5343,8 @@ async function installCommand(opts = {}) {
4725
5343
  bashFollowupScriptPath: scripts.bashFollowupScript,
4726
5344
  editCaptureScriptPath: scripts.editCaptureScript,
4727
5345
  editPrecheckScriptPath: scripts.editPrecheckScript,
4728
- cveScanScriptPath: scripts.cveScanScript,
4729
- cweScanScriptPath: scripts.cweScanScript,
5346
+ cwePrecheckScriptPath: scripts.cwePrecheckScript,
5347
+ cvePrecheckScriptPath: scripts.cvePrecheckScript,
4730
5348
  planJudgeScriptPath: scripts.planJudgeScript,
4731
5349
  stopSummaryScriptPath: scripts.stopSummaryScript,
4732
5350
  sessionStartScriptPath: scripts.sessionStartScript,
@@ -5131,6 +5749,7 @@ var init_install2 = __esm({
5131
5749
  init_cursorHookConfig();
5132
5750
  init_mcpConfig();
5133
5751
  init_hookScripts();
5752
+ init_hookScriptsTs();
5134
5753
  init_stub();
5135
5754
  init_repoConnect();
5136
5755
  init_projects();