@synkro-sh/cli 1.4.14 → 1.4.15

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
@@ -432,204 +432,164 @@ var init_mcpConfig = __esm({
432
432
  });
433
433
 
434
434
  // cli/installer/hookScripts.ts
435
- var CC_BASH_JUDGE_SCRIPT, CC_EDIT_PRECHECK_SCRIPT, CC_EDIT_CAPTURE_SCRIPT, CC_STOP_SUMMARY_SCRIPT, CC_SESSION_START_SCRIPT, CC_BASH_FOLLOWUP_SCRIPT, CC_TRANSCRIPT_SYNC_SCRIPT, SYNKRO_COMMON_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
435
+ var SYNKRO_COMMON_SCRIPT, CC_BASH_JUDGE_SCRIPT, CC_EDIT_PRECHECK_SCRIPT, CC_EDIT_CAPTURE_SCRIPT, CC_STOP_SUMMARY_SCRIPT, CC_SESSION_START_SCRIPT, CC_BASH_FOLLOWUP_SCRIPT, CC_TRANSCRIPT_SYNC_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
436
436
  var init_hookScripts = __esm({
437
437
  "cli/installer/hookScripts.ts"() {
438
438
  "use strict";
439
- CC_BASH_JUDGE_SCRIPT = `#!/bin/bash
440
- # Synkro PreToolUse Bash judge hook
441
- # Reads CC's hook payload from stdin, judges via Synkro gateway, returns verdict.
442
- # Auth: reads access_token from ~/.synkro/credentials.json, sends Authorization: Bearer.
443
- # No set -e: hook must ALWAYS produce JSON output. Silent death = CC timeout.
439
+ SYNKRO_COMMON_SCRIPT = `#!/bin/bash
440
+ # Shared Synkro hook utilities \u2014 sourced by all hook scripts.
444
441
 
445
442
  synkro_log() { echo "[synkro] $1" >&2; }
446
443
 
447
- # True if anything is listening on the local-cc channel TCP port.
448
- synkro_channel_up() {
449
- (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
450
- }
451
-
452
444
  # Load config
453
- CONFIG_FILE="$HOME/.synkro/config.env"
454
- if [ -f "$CONFIG_FILE" ]; then
455
- set -a
456
- # shellcheck disable=SC1090
457
- . "$CONFIG_FILE"
458
- set +a
445
+ _SYNKRO_CONFIG="$HOME/.synkro/config.env"
446
+ if [ -f "$_SYNKRO_CONFIG" ]; then
447
+ set -a; . "$_SYNKRO_CONFIG"; set +a
459
448
  fi
460
449
 
461
450
  GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
462
451
  CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
463
452
 
464
- # Fail open if not authed
465
- if [ ! -f "$CREDS_PATH" ]; then
453
+ synkro_load_jwt() {
454
+ if [ ! -f "$CREDS_PATH" ]; then echo ""; return 1; fi
455
+ jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null
456
+ }
466
457
 
467
- echo '{}'
468
- exit 0
469
- fi
470
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
471
- if [ -z "$JWT" ]; then
458
+ synkro_refresh_jwt() {
459
+ local rt
460
+ rt=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
461
+ if [ -z "$rt" ]; then return 1; fi
462
+ local resp
463
+ resp=$(curl -sS -X POST "\${GATEWAY_URL}/api/auth/refresh" \\
464
+ -H "Content-Type: application/json" \\
465
+ -d "$(jq -n --arg rt "$rt" '{refresh_token:$rt}')" \\
466
+ --max-time 4 2>/dev/null)
467
+ local new_at
468
+ new_at=$(echo "$resp" | jq -r '.access_token // empty' 2>/dev/null)
469
+ if [ -z "$new_at" ]; then return 1; fi
470
+ local new_rt
471
+ new_rt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
472
+ [ -z "$new_rt" ] && new_rt="$rt"
473
+ local tmp="\${CREDS_PATH}.synkro.tmp"
474
+ 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"
475
+ JWT="$new_at"
476
+ }
472
477
 
473
- echo '{}'
474
- exit 0
475
- fi
478
+ synkro_ensure_fresh_jwt() {
479
+ [ -z "$JWT" ] && return 1
480
+ local p exp now
481
+ p=$(printf '%s' "$JWT" | cut -d. -f2)
482
+ case $((\${#p} % 4)) in 2) p="\${p}==";; 3) p="\${p}=";; esac
483
+ exp=$(printf '%s' "$p" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
484
+ now=$(date -u +%s)
485
+ [ $((exp - now)) -lt 60 ] && synkro_refresh_jwt
486
+ }
476
487
 
477
- # Read hook payload from stdin (CC sends a single JSON line)
478
- PAYLOAD=$(cat)
479
- if [ -z "$PAYLOAD" ]; then
480
- echo '{}'
481
- exit 0
482
- fi
488
+ synkro_detect_repo() {
489
+ local cwd="\${1:-.}"
490
+ if command -v git >/dev/null 2>&1; then
491
+ local r
492
+ r=$(git -C "$cwd" remote get-url origin 2>/dev/null || true)
493
+ [ -n "$r" ] && echo "$r" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||' && return
494
+ fi
495
+ echo ""
496
+ }
483
497
 
484
- # Translate tool calls into a command string for the judge
485
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
486
- case "$TOOL_NAME" in
487
- Bash)
488
- COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
489
- ;;
490
- Read)
491
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
492
- COMMAND="cat \${FILE_PATH}"
493
- ;;
494
- Grep)
495
- PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
496
- GREP_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.path // "."' 2>/dev/null)
497
- COMMAND="grep -r '\${PATTERN}' \${GREP_PATH}"
498
- ;;
499
- Glob)
500
- PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
501
- COMMAND="find . -name '\${PATTERN}'"
502
- ;;
503
- *)
504
- echo '{}'
505
- exit 0
506
- ;;
507
- esac
508
- if [ -z "$COMMAND" ]; then
509
- echo '{}'
510
- exit 0
511
- fi
498
+ synkro_post_with_retry() {
499
+ local url="$1" body="$2" timeout="\${3:-8}"
500
+ local resp
501
+ resp=$(curl -sS -X POST "$url" \\
502
+ -H "Content-Type: application/json" \\
503
+ -H "Authorization: Bearer $JWT" \\
504
+ -d "$body" --max-time "$timeout" 2>/dev/null || echo "")
505
+ if echo "$resp" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
506
+ if synkro_refresh_jwt; then
507
+ resp=$(curl -sS -X POST "$url" \\
508
+ -H "Content-Type: application/json" \\
509
+ -H "Authorization: Bearer $JWT" \\
510
+ -d "$body" --max-time "$timeout" 2>/dev/null || echo "")
511
+ fi
512
+ fi
513
+ echo "$resp"
514
+ }
515
+ `;
516
+ CC_BASH_JUDGE_SCRIPT = `#!/bin/bash
517
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
518
+ . "$SCRIPT_DIR/_synkro-common.sh"
512
519
 
513
- CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
514
- synkro_log "bashGuard checking: $CMD_SHORT"
520
+ JWT=$(synkro_load_jwt)
521
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
522
+ synkro_ensure_fresh_jwt
515
523
 
516
- # All commands are graded; no client-side regex gate.
524
+ PAYLOAD=$(cat)
525
+ if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
526
+
527
+ TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
528
+ case "$TOOL_NAME" in Bash|Read|Grep|Glob) ;; *) echo '{}'; exit 0 ;; esac
517
529
 
518
- # Extract context from the transcript file
519
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
520
530
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
521
531
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
522
532
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
523
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c --arg cmd "$COMMAND" '.tool_input // {} | . + {command: $cmd}' 2>/dev/null)
524
- # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
525
- GIT_REPO=""
526
- if command -v git >/dev/null 2>&1; then
527
- _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
528
- if [ -n "$_REMOTE" ]; then
529
- GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
530
- fi
531
- fi
532
- # Headless detection \u2014 when no human is in the loop, ASK is a no-op so we
533
- # fail-closed by upgrading high-tier findings to deny.
533
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
534
534
  PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
535
- IS_HEADLESS=0
536
- case "$PERMISSION_MODE" in
537
- acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS=1 ;;
535
+
536
+ # Translate tool calls to command string for logging
537
+ case "$TOOL_NAME" in
538
+ Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
539
+ Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
540
+ 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)" ;;
541
+ Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
538
542
  esac
539
- if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
543
+ if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
544
+
545
+ CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
546
+ synkro_log "bashGuard checking: $CMD_SHORT"
540
547
 
548
+ # Extract transcript context
549
+ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
541
550
  USER_INTENT=""
542
551
  RECENT_USER_MESSAGES="[]"
543
552
  RECENT_MESSAGES="[]"
544
553
  RECENT_ACTIONS="[]"
545
554
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
546
555
  RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
547
- [.[]
548
- | select(.type == "user")
549
- | (.message.content
550
- | if type == "string" then .
551
- else (map(.text? // "") | join(" "))
552
- end)
556
+ [.[] | select(.type == "user") | (.message.content
557
+ | if type == "string" then . else (map(.text? // "") | join(" ")) end)
553
558
  | select(. != null and . != "")
554
559
  ] | .[-5:]' 2>/dev/null || echo "[]")
555
560
  USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
556
- # Interleaved assistant+user messages \u2014 lets the grader see what question
557
- # each "yes" was answering (assistant text before user reply).
558
561
  RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
559
- [.[]
560
- | select(.type == "assistant" or .type == "user")
561
- | {
562
- role: .type,
563
- text: (
564
- if .type == "assistant" then
565
- [.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:500]
566
- else
567
- (.message.content
568
- | if type == "string" then .[0:500]
569
- else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:500])
570
- end)
571
- end
572
- )
573
- }
574
- | select(.text != "" and .text != null and (.text | length) > 0)
562
+ [.[] | select(.type == "user" or .type == "assistant")
563
+ | {type, text: (.message.content | if type == "string" then .[0:500]
564
+ else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}
575
565
  ] | .[-10:]' 2>/dev/null || echo "[]")
576
- # Recent agent actions (last 5 tool_use blocks paired with results)
577
566
  RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
578
- # tool_result blocks live in USER messages
579
- ([ .[]
580
- | select(.type == "user")
581
- | .message.content[]?
582
- | select(type == "object" and .type == "tool_result")
583
- | { (.tool_use_id): (.content // "" | tostring | .[0:300]) }
584
- ] | add // {}) as $results
585
- |
586
- [ .[]
587
- | select(.type == "assistant")
588
- | .message.content[]?
589
- | select(.type == "tool_use")
590
- | {
591
- tool: .name,
592
- input: (.input // {} | tostring | .[0:200]),
593
- result: ($results[.id] // null)
594
- }
567
+ [.[] | select(.type == "assistant") | .message.content[]?
568
+ | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}
595
569
  ] | .[-5:]' 2>/dev/null || echo "[]")
596
570
  fi
597
571
 
572
+ # Extract CC model + usage from last assistant turn
598
573
  CC_MODEL=""
599
574
  CC_USAGE="{}"
600
575
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
601
- _LAST_ASSISTANT=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
602
- if [ -n "$_LAST_ASSISTANT" ]; then
603
- CC_MODEL=$(echo "$_LAST_ASSISTANT" | jq -r '.message.model // empty' 2>/dev/null)
604
- CC_USAGE=$(echo "$_LAST_ASSISTANT" | jq -c '{
605
- input_tokens: .message.usage.input_tokens,
606
- output_tokens: .message.usage.output_tokens,
607
- cache_creation_input_tokens: .message.usage.cache_creation_input_tokens,
608
- cache_read_input_tokens: .message.usage.cache_read_input_tokens,
609
- service_tier: .message.usage.service_tier,
610
- speed: .message.usage.speed
611
- }' 2>/dev/null || echo "{}")
576
+ _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
577
+ if [ -n "$_LAST" ]; then
578
+ CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
579
+ 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 "{}")
612
580
  fi
613
581
  fi
614
582
 
615
- # Extract session summary from CC compaction (free broad context)
583
+ # Session summary from last summary entry
616
584
  SESSION_SUMMARY=""
617
585
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
618
- _SUMMARY_LINE=$(grep -n '"This session is being continued' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | cut -d: -f1)
619
- if [ -n "$_SUMMARY_LINE" ]; then
620
- SESSION_SUMMARY=$(sed -n "\${_SUMMARY_LINE}p" "$TRANSCRIPT_PATH" | jq -r '
621
- .message.content
622
- | if type == "string" then .[0:2000]
623
- else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:2000])
624
- end' 2>/dev/null || echo "")
625
- fi
586
+ SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
626
587
  fi
627
588
 
628
- # Build POST body \u2014 always emit all fields (use null for empty optionals)
629
- # Earlier version used \`select(length > 0)\` which made the entire object
630
- # evaluate to nothing when any optional was empty. Don't do that.
631
589
  BODY=$(jq -n \\
632
- --argjson tool_input "$TOOL_INPUT" \\
590
+ --arg hook_event "PreToolUse" \\
591
+ --arg tool_name "$TOOL_NAME" \\
592
+ --argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
633
593
  --arg user_intent "$USER_INTENT" \\
634
594
  --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
635
595
  --argjson recent_messages "$RECENT_MESSAGES" \\
@@ -638,11 +598,13 @@ BODY=$(jq -n \\
638
598
  --arg tool_use_id "$TOOL_USE_ID" \\
639
599
  --arg cwd "$CWD" \\
640
600
  --arg repo "$GIT_REPO" \\
601
+ --arg permission_mode "$PERMISSION_MODE" \\
641
602
  --arg cc_model "$CC_MODEL" \\
642
603
  --argjson cc_usage "$CC_USAGE" \\
643
604
  --arg session_summary "$SESSION_SUMMARY" \\
644
605
  '{
645
- kind: "bash_judge",
606
+ hook_event: $hook_event,
607
+ tool_name: $tool_name,
646
608
  tool_input: $tool_input,
647
609
  user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
648
610
  recent_user_messages: $recent_user_messages,
@@ -652,437 +614,66 @@ BODY=$(jq -n \\
652
614
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
653
615
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
654
616
  repo: (if ($repo | length) > 0 then $repo else null end),
617
+ permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
655
618
  cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
656
619
  cc_usage: $cc_usage,
657
620
  session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
658
621
  }')
659
622
 
660
- # Helper: refresh JWT via /api/auth/refresh and rewrite credentials.json.
661
- # Called when the gateway returns 401 (token expired).
662
- refresh_jwt() {
663
- local refresh_token
664
- refresh_token=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
665
- if [ -z "$refresh_token" ]; then
666
- return 1
667
- fi
668
- local refresh_resp
669
- local refresh_body
670
- refresh_body=$(jq -n --arg rt "$refresh_token" '{refresh_token:$rt}')
671
- refresh_resp=$(curl -sS -X POST "\${GATEWAY_URL}/api/auth/refresh" \\
672
- -H "Content-Type: application/json" \\
673
- -d "$refresh_body" \\
674
- --max-time 4 2>/dev/null)
675
- local new_access
676
- new_access=$(echo "$refresh_resp" | jq -r '.access_token // empty' 2>/dev/null)
677
- if [ -z "$new_access" ]; then
678
- return 1
679
- fi
680
- # Atomically rewrite credentials.json with the new tokens
681
- local new_refresh
682
- new_refresh=$(echo "$refresh_resp" | jq -r '.refresh_token // empty' 2>/dev/null)
683
- if [ -z "$new_refresh" ]; then
684
- new_refresh="$refresh_token"
685
- fi
686
- local tmp="\${CREDS_PATH}.synkro.tmp"
687
- jq --arg at "$new_access" --arg rt "$new_refresh" \\
688
- '. + {access_token: $at, refresh_token: $rt}' \\
689
- "$CREDS_PATH" > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
690
- JWT="$new_access"
691
- return 0
692
- }
693
-
694
- ensure_fresh_jwt() {
695
- [ -z "$JWT" ] && return 1
696
- local payload exp now remaining
697
- payload=$(printf '%s' "$JWT" | cut -d. -f2)
698
- case $((\${#payload} % 4)) in
699
- 2) payload="\${payload}==" ;;
700
- 3) payload="\${payload}=" ;;
701
- esac
702
- exp=$(printf '%s' "$payload" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
703
- now=$(date -u +%s)
704
- remaining=$((exp - now))
705
- if [ "$remaining" -lt 60 ]; then
706
- refresh_jwt
707
- fi
708
- }
623
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
709
624
 
710
- ensure_fresh_jwt
711
-
712
- # Single fetch: /cli/hook-context returns me + rules in one call.
713
- HOOK_CTX=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/hook-context" -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
714
- SYNKRO_INFERENCE_TIER=$(echo "$HOOK_CTX" | jq -r '.tier // empty' 2>/dev/null)
715
- SYNKRO_CAPTURE_DEPTH=$(echo "$HOOK_CTX" | jq -r '.capture_depth // empty' 2>/dev/null)
716
- SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-fast}"
717
- SYNKRO_CAPTURE_DEPTH="\${SYNKRO_CAPTURE_DEPTH:-local_only}"
718
-
719
- USE_LOCAL=false
720
- if command -v claude >/dev/null 2>&1; then
721
- USE_LOCAL=true
722
- fi
723
-
724
- if synkro_channel_up || { [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; }; then
725
- RULES_CACHE="$HOME/.synkro/.rules-cache-bash"
726
- RULES_JQ='[.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id, text, severity, category}]'
727
- ORG_RULES=$(echo "$HOOK_CTX" | jq -c "$RULES_JQ" 2>/dev/null || echo "[]")
728
- if [ -n "$ORG_RULES" ] && [ "$ORG_RULES" != "null" ] && [ "$ORG_RULES" != "[]" ]; then
729
- printf '%s' "$ORG_RULES" > "$RULES_CACHE" 2>/dev/null || true
730
- elif [ -f "$RULES_CACHE" ]; then
731
- ORG_RULES=$(cat "$RULES_CACHE" 2>/dev/null || echo "[]")
732
- fi
733
- if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
734
-
735
- GRADER_PROMPT_FILE=$(mktemp -t synkro-bash-prompt.XXXXXX)
736
- trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
737
- printf 'Proposed command: %s\\n' "$COMMAND" > "$GRADER_PROMPT_FILE"
738
- printf 'User intent: %s\\n' "\${USER_INTENT:-none stated}" >> "$GRADER_PROMPT_FILE"
739
- printf 'Recent user messages: %s\\n' "$RECENT_USER_MESSAGES" >> "$GRADER_PROMPT_FILE"
740
- printf 'Recent actions: %s\\n' "$RECENT_ACTIONS" >> "$GRADER_PROMPT_FILE"
741
- printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
742
-
743
- ROUTE_TAG=""
744
- if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
745
- ROUTE_TAG="local-cc"
746
- CC_RESP=$(node "$SYNKRO_CLI_BIN" grade bash < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
747
- elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
748
- ROUTE_TAG="local-cc-path"
749
- CC_RESP=$(synkro grade bash < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
750
- else
751
- # Channel unavailable \u2014 fall back to cold \`claude --print\` (free tier path).
752
- ROUTE_TAG="cc-print"
753
- CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
754
- fi
755
- # Wrapper extraction \u2014 greedy so it tolerates nested XML tags inside.
756
- V_INNER=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
757
-
758
- # Local primer emits XML tags. Parse into the same bash vars the
759
- # downstream code expects \u2014 bypassing jq entirely (XML is faster on local
760
- # claude --print than JSON-mode generation).
761
- xtag() { printf '%s' "$1" | sed -nE "s|.*<$2>(.*)</$2>.*|\\1|p" | head -1; }
762
- VERDICT_KIND=$(xtag "$V_INNER" verdict)
763
- SEVERITY=$(xtag "$V_INNER" severity)
764
- RISK_LEVEL=$(xtag "$V_INNER" risk_level)
765
- CATEGORY=$(xtag "$V_INNER" category)
766
- REASONING=$(xtag "$V_INNER" reasoning)
767
- ALTERNATIVE=$(xtag "$V_INNER" alternative)
768
-
769
- # Fail-open on no verdict \u2014 daemon handles cold fallback internally; if it
770
- # still came back empty (grader unavailable), don't block the user.
771
- if [ -z "$VERDICT_KIND" ] || [ -z "$SEVERITY" ]; then
772
- synkro_log "bashGuard $CMD_SHORT \u2192 pass (grader unavailable)"
773
- jq -n --arg m "[synkro] bashGuard \u2192 pass (grader unavailable)" '{systemMessage: $m}'
774
- exit 0
775
- fi
776
- # Sentinel \u2014 tells the downstream parser to skip jq and use the bash vars
777
- # already populated above. Server-side path (else branch) keeps using JSON.
778
- VERDICT="__LOCAL_XML_PARSED__"
779
- else
780
- # \u2500\u2500\u2500 Server-side grading. \u2500\u2500\u2500
781
-
782
- VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
783
- -H "Content-Type: application/json" \\
784
- -H "Authorization: Bearer $JWT" \\
785
- -d "$BODY" \\
786
- --max-time 6 2>/dev/null || echo "")
787
-
788
- if echo "$VERDICT" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
789
-
790
- if refresh_jwt; then
791
- VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
792
- -H "Content-Type: application/json" \\
793
- -H "Authorization: Bearer $JWT" \\
794
- -d "$BODY" \\
795
- --max-time 6 2>/dev/null || echo "")
796
- fi
797
- fi
798
- fi
799
-
800
- if [ -z "$VERDICT" ]; then
625
+ if [ -z "$RESP" ]; then
801
626
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
802
- jq -n --arg m "[synkro:\${ROUTE_TAG:-cloud}] bashGuard \u2192 error (timeout)" '{systemMessage: $m}'
627
+ echo '{}'
803
628
  exit 0
804
629
  fi
805
630
 
806
- # Parse verdict \u2014 fail open on any parse error.
807
- # Local XML path already populated the vars above; only parse JSON for the
808
- # server (BYOK) path.
809
- if [ "$VERDICT" != "__LOCAL_XML_PARSED__" ]; then
810
- SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "audit"' 2>/dev/null)
811
- VERDICT_KIND=$(echo "$VERDICT" | jq -r '.verdict // "warn"' 2>/dev/null)
812
- REASONING=$(echo "$VERDICT" | jq -r '.reasoning // "matched dangerous-verb regex"' 2>/dev/null)
813
- ALTERNATIVE=$(echo "$VERDICT" | jq -r '.alternative // ""' 2>/dev/null)
814
- CATEGORY=$(echo "$VERDICT" | jq -r '.category // "destructive_command"' 2>/dev/null)
815
- RISK_LEVEL=$(echo "$VERDICT" | jq -r '.risk_level // empty' 2>/dev/null)
816
- VIOLATED_RULES=$(echo "$VERDICT" | jq -c '.violated_rules // []' 2>/dev/null)
817
- fi
818
- VIOLATED_RULES="\${VIOLATED_RULES:-[]}"
819
- # Defaults if any var is empty (defensive \u2014 XML primer should always emit them).
820
- SEVERITY="\${SEVERITY:-audit}"
821
- VERDICT_KIND="\${VERDICT_KIND:-warn}"
822
- REASONING="\${REASONING:-matched dangerous-verb regex}"
823
- CATEGORY="\${CATEGORY:-destructive_command}"
824
- SYNKRO_PREFIX="[synkro:\${ROUTE_TAG:-cloud}]"
825
-
826
- # Backwards-compat: if severity isn't block/audit, derive it from verdict_kind
827
- # and treat the original severity as the risk_level.
828
- case "$SEVERITY" in
829
- block|audit) ;;
830
- low|medium|high|critical)
831
- [ -z "$RISK_LEVEL" ] && RISK_LEVEL="$SEVERITY"
832
- if [ "$VERDICT_KIND" = "allow" ]; then SEVERITY="audit"; else SEVERITY="block"; fi
833
- ;;
834
- *)
835
- if [ "$VERDICT_KIND" = "allow" ]; then SEVERITY="audit"; else SEVERITY="block"; fi
836
- ;;
837
- esac
838
-
839
- # Severity-driven surfacing:
840
- # block \u2192 permissionDecision: "ask" (interactive) or "deny" (headless)
841
- # audit \u2192 silent allow \u2014 logged but no interruption
842
-
843
- ALT_SUFFIX=""
844
- if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
845
- ALT_SUFFIX=" Suggested: \${ALTERNATIVE}"
846
- fi
847
-
848
- case "$SEVERITY" in
849
- block)
850
- PERMISSION_REASON="\${SYNKRO_PREFIX} \${REASONING}\${ALT_SUFFIX}"
851
- ADDITIONAL_CTX="Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}, route: \${ROUTE_TAG:-cloud}). Reasoning: \${REASONING}.\${ALT_SUFFIX}"
852
- if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
853
- jq -n \\
854
- --arg ctx "$ADDITIONAL_CTX" \\
855
- --arg reason "$PERMISSION_REASON" \\
856
- --arg decision "$DECISION" \\
857
- '{
858
- hookSpecificOutput: {
859
- hookEventName: "PreToolUse",
860
- permissionDecision: $decision,
861
- permissionDecisionReason: $reason,
862
- additionalContext: $ctx
863
- }
864
- }'
865
- ;;
866
- audit)
867
- synkro_log "bashGuard $CMD_SHORT \u2192 pass ($CATEGORY): $REASONING"
868
- case "$CATEGORY" in
869
- trivial_utility)
870
- jq -n --arg m "\${SYNKRO_PREFIX} bashGuard \u2192 pass" '{systemMessage: $m}' ;;
871
- judge_unavailable|judge_error|parse_error)
872
- jq -n --arg m "\${SYNKRO_PREFIX} bashGuard \u2192 pass (grader unavailable)" '{systemMessage: $m}' ;;
873
- *)
874
- jq -n --arg m "\${SYNKRO_PREFIX} bashGuard \u2192 pass (\${CATEGORY}): \${REASONING}" '{systemMessage: $m}' ;;
875
- esac
876
- ;;
877
- *)
878
- synkro_log "bashGuard $CMD_SHORT \u2192 UNEXPECTED SEVERITY ($SEVERITY), blocking by default"
879
- if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
880
- jq -n \\
881
- --arg decision "$DECISION" \\
882
- --arg reason "\${SYNKRO_PREFIX} unexpected severity '\${SEVERITY}' \u2014 blocking by default. Please email team@synkro.sh to report this issue." \\
883
- '{
884
- hookSpecificOutput: {
885
- hookEventName: "PreToolUse",
886
- permissionDecision: $decision,
887
- permissionDecisionReason: $reason,
888
- additionalContext: "Synkro safety judge returned an unexpected severity value. This command has been blocked as a precaution. Please email team@synkro.sh with details of the command you were running."
889
- }
890
- }'
891
- ;;
892
- esac
893
-
894
- # Fire-and-forget telemetry for locally-judged checks
895
- if [ "$USE_LOCAL" = "true" ] && [ -n "$VERDICT_KIND" ]; then
896
- (
897
- MECH_CAT=""
898
- BIZ_CAT=""
899
- # For violations, run OWASP classification on user's machine
900
- if [ "$VERDICT_KIND" = "warn" ]; then
901
- CLASS_CACHE="$HOME/.synkro/.classification-prompt"
902
- CLASS_PROMPT=""
903
- if [ -f "$CLASS_CACHE" ] && find "$CLASS_CACHE" -mmin -1440 2>/dev/null | grep -q .; then
904
- CLASS_PROMPT=$(cat "$CLASS_CACHE" 2>/dev/null)
905
- else
906
- CLASS_PROMPT=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/judge-prompts" \\
907
- -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null | jq -r '.classification_prompt // empty')
908
- [ -n "$CLASS_PROMPT" ] && echo "$CLASS_PROMPT" > "$CLASS_CACHE"
909
- fi
910
- if [ -n "$CLASS_PROMPT" ]; then
911
- CLASS_INPUT=$(printf '%s\\n\\nViolation context:\\n- Tool: %s\\n- Category: %s\\n- Severity: %s\\n- Hook type: bash command judge' "$CLASS_PROMPT" "$TOOL_NAME" "$CATEGORY" "$SEVERITY")
912
- CLASS_RESP=$(echo "$CLASS_INPUT" | claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null || echo "")
913
- MECH_CAT=$(echo "$CLASS_RESP" | grep -oE '<mechanism>[^<]+</mechanism>' | sed 's/<[^>]*>//g')
914
- BIZ_CAT=$(echo "$CLASS_RESP" | grep -oE '<business>[^<]+</business>' | sed 's/<[^>]*>//g')
915
- fi
916
- fi
917
- TEL_BODY=$(jq -n \\
918
- --arg event_id "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
919
- --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
920
- --arg hook_type "bash" \\
921
- --arg verdict "$VERDICT_KIND" \\
922
- --arg severity "$SEVERITY" \\
923
- --arg risk_level "\${RISK_LEVEL:-low}" \\
924
- --arg category "$CATEGORY" \\
925
- --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
926
- --arg tool_name "$TOOL_NAME" \\
927
- --arg repo "\${GIT_REPO:-}" \\
928
- --arg session_id "$SESSION_ID" \\
929
- --arg tool_use_id "\${TOOL_USE_ID:-}" \\
930
- --arg cwd "\${CWD:-}" \\
931
- --arg mech_cat "$MECH_CAT" \\
932
- --arg biz_cat "$BIZ_CAT" \\
933
- --arg capture_depth "$SYNKRO_CAPTURE_DEPTH" \\
934
- --arg command "$COMMAND" \\
935
- --arg reasoning "$REASONING" \\
936
- --arg alternative "$ALTERNATIVE" \\
937
- --argjson rules_checked "\${ORG_RULES:-[]}" \\
938
- --argjson violated_rules "\${VIOLATED_RULES:-[]}" \\
939
- --argjson recent_user_messages "\${RECENT_USER_MESSAGES:-[]}" \\
940
- '{
941
- event_id: $event_id,
942
- timestamp: $timestamp,
943
- hook_type: $hook_type,
944
- verdict: $verdict,
945
- severity: $severity,
946
- risk_level: $risk_level,
947
- category: $category,
948
- model: $model,
949
- tool_name: $tool_name,
950
- capture_depth: $capture_depth,
951
- rules_checked: $rules_checked,
952
- violated_rules: $violated_rules
953
- } + (if $repo != "" then {repo: $repo} else {} end)
954
- + (if $session_id != "" then {session_id: $session_id} else {} end)
955
- + (if $tool_use_id != "" then {tool_use_id: $tool_use_id} else {} end)
956
- + (if $cwd != "" then {cwd: $cwd} else {} end)
957
- + (if $mech_cat != "" then {mechanism_category: $mech_cat} else {} end)
958
- + (if $biz_cat != "" then {business_category: $biz_cat} else {} end)
959
- + (if $capture_depth != "local_only" then {command: $command, reasoning: $reasoning, recent_user_messages: $recent_user_messages} + (if $alternative != "" then {alternative: $alternative} else {} end) else {} end)')
960
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
961
- -H "Content-Type: application/json" \\
962
- -H "Authorization: Bearer $JWT" \\
963
- -d "$TEL_BODY" \\
964
- --max-time 2 >/dev/null 2>&1
965
- ) &
631
+ if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
632
+ synkro_log "bashGuard $CMD_SHORT \u2192 pass (no hook_response)"
633
+ echo '{}'
634
+ exit 0
966
635
  fi
967
636
 
637
+ echo "$RESP" | jq -c '.hook_response'
968
638
  exit 0
969
639
  `;
970
640
  CC_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
971
- # Synkro PreToolUse Edit/Write/MultiEdit/NotebookEdit pre-check hook.
972
- # Always exits 0 with valid JSON. Fails open on any error.
973
-
974
- synkro_log() { echo "[synkro] $1" >&2; }
975
-
976
- # True if anything is listening on the local-cc channel TCP port.
977
- synkro_channel_up() {
978
- (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
979
- }
980
-
981
- CONFIG_FILE="$HOME/.synkro/config.env"
982
- if [ -f "$CONFIG_FILE" ]; then
983
- set -a
984
- # shellcheck disable=SC1090
985
- . "$CONFIG_FILE"
986
- set +a
987
- fi
988
-
989
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
990
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
641
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
642
+ . "$SCRIPT_DIR/_synkro-common.sh"
991
643
 
992
- if [ ! -f "$CREDS_PATH" ]; then
993
- echo '{}'
994
- exit 0
995
- fi
996
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
997
- if [ -z "$JWT" ]; then
998
- echo '{}'
999
- exit 0
1000
- fi
644
+ JWT=$(synkro_load_jwt)
645
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
646
+ synkro_ensure_fresh_jwt
1001
647
 
1002
648
  PAYLOAD=$(cat)
1003
- if [ -z "$PAYLOAD" ]; then
1004
- echo '{}'
1005
- exit 0
1006
- fi
649
+ if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1007
650
 
1008
651
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1009
- case "$TOOL_NAME" in
1010
- Edit|Write|MultiEdit|NotebookEdit) ;;
1011
- *) echo '{}'; exit 0 ;;
1012
- esac
652
+ case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1013
653
 
1014
654
  TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1015
655
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1016
656
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1017
657
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1018
- # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
1019
- GIT_REPO=""
1020
- if command -v git >/dev/null 2>&1; then
1021
- _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
1022
- if [ -n "$_REMOTE" ]; then
1023
- GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
1024
- fi
1025
- fi
1026
- # Headless / non-interactive detection \u2014 when CC won't actually prompt the
1027
- # human, our "ask" verdict is a no-op. Server uses these to fall back to
1028
- # "deny" so we fail-closed instead of silently letting findings through.
658
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1029
659
  PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
1030
- HEADLESS_FLAG="\${SYNKRO_HEADLESS:-0}"
1031
660
 
1032
661
  FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1033
- if [ -z "$FILE_PATH" ]; then
1034
- echo '{}'
1035
- exit 0
1036
- fi
662
+ if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1037
663
 
1038
664
  FILE_SHORT=$(basename "$FILE_PATH")
1039
665
  synkro_log "editGuard checking: $FILE_SHORT"
1040
666
 
1041
- # Pull conversation context from the transcript file. CC writes one JSON line
1042
- # per message; we read the tail and extract the most recent user message + the
1043
- # last 5 tool_use blocks. The server uses these as anchors for cosine ranking
1044
- # AND as a suppression signal when the user explicitly asked for the unsafe
1045
- # pattern. Same trick the bash judge uses.
1046
- USER_INTENT=""
1047
- RECENT_ACTIONS="[]"
1048
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1049
- USER_INTENT=$(tail -200 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "user") | .message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end' 2>/dev/null | tail -1 || echo "")
1050
- RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '
1051
- [.[]
1052
- | select(.type == "assistant")
1053
- | .message.content[]?
1054
- | select(.type == "tool_use")
1055
- | { tool: .name, input: (.input // {} | tostring | .[0:200]) }
1056
- ] | .[-5:]' 2>/dev/null || echo "[]")
1057
- fi
1058
-
1059
- # Read the on-disk file FIRST so the Edit/MultiEdit branches below can
1060
- # reconstruct the full post-edit file (file_before with the diff applied).
1061
- # Cap at 64KB. Must run before the case statement so PROPOSED can include
1062
- # whole-file context, not just the diff hunk.
667
+ # Read file before edit for reconstruction
1063
668
  FILE_BEFORE=""
1064
669
  if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
1065
670
  FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1066
671
  fi
1067
672
 
1068
- # Pull proposed content. Edit/MultiEdit reconstruct the full file via
1069
- # bash parameter expansion against FILE_BEFORE; Write/NotebookEdit pass
1070
- # the new content directly.
673
+ # Reconstruct proposed content
1071
674
  case "$TOOL_NAME" in
1072
- Write)
1073
- # Write replaces the entire file \u2014 content IS the full post-edit file.
1074
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
675
+ Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
1075
676
  Edit|MultiEdit)
1076
- # Reconstruct the full post-edit file by applying the diff to file_before.
1077
- # Sending only new_string (the diff hunk) blinds the local grader to
1078
- # violations elsewhere in the file \u2014 the grader needs whole-file context
1079
- # to identify multi-violation edits and cross-line patterns.
1080
- #
1081
- # We use python (already a daemon dependency) instead of bash parameter
1082
- # expansion because macOS ships bash 3.2, where the quoted-pattern form
1083
- # of \${var//PAT/REPL} leaks the quote characters into the result.
1084
- # Python's str.replace() handles arbitrary strings cleanly. Args go via
1085
- # env vars (not argv) so 64 KB file content doesn't trip ARG_MAX limits.
1086
677
  if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
1087
678
  PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
1088
679
  import os, json, sys
@@ -1090,47 +681,43 @@ fb = os.environ.get("FILE_BEFORE_LITERAL", "")
1090
681
  ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
1091
682
  result = fb
1092
683
  if "old_string" in ti and "new_string" in ti:
1093
- if ti["old_string"]:
1094
- result = result.replace(ti["old_string"], ti["new_string"], 1)
684
+ if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
1095
685
  elif "edits" in ti and isinstance(ti["edits"], list):
1096
686
  for e in ti["edits"]:
1097
687
  old = e.get("old_string", "") if isinstance(e, dict) else ""
1098
688
  new = e.get("new_string", "") if isinstance(e, dict) else ""
1099
- if old:
1100
- result = result.replace(old, new, 1)
689
+ if old: result = result.replace(old, new, 1)
1101
690
  sys.stdout.write(result)
1102
691
  ' 2>/dev/null)
1103
692
  fi
1104
- # Fall back to the diff-hunk-only shape if reconstruction failed or
1105
- # file_before was empty (new file via Edit, etc.).
1106
693
  if [ -z "$PROPOSED" ]; then
1107
694
  if [ "$TOOL_NAME" = "MultiEdit" ]; then
1108
695
  PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
1109
696
  else
1110
697
  PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
1111
698
  fi
1112
- fi
1113
- ;;
1114
- NotebookEdit)
1115
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
699
+ fi ;;
700
+ NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
1116
701
  esac
1117
-
1118
- if [ -z "$PROPOSED" ]; then
1119
- echo '{}'
1120
- exit 0
1121
- fi
702
+ if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
1122
703
 
1123
704
  DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null)
1124
- if [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ]; then
1125
- DIFF_FIELD="null"
1126
- fi
705
+ [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
1127
706
 
1128
- # FILE_BEFORE was read above (before the case statement) so this body
1129
- # construction just references it; the duplicate read used to live here.
707
+ # Extract user intent from transcript
708
+ USER_INTENT=""
709
+ RECENT_ACTIONS="[]"
710
+ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
711
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
712
+ USER_INTENT=$(tail -200 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "user") | .message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end' 2>/dev/null | tail -1 || echo "")
713
+ 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 "[]")
714
+ fi
1130
715
 
1131
716
  BODY=$(jq -n \\
1132
- --arg file_path "$FILE_PATH" \\
717
+ --arg hook_event "PreToolUse" \\
1133
718
  --arg tool_name "$TOOL_NAME" \\
719
+ --argjson tool_input "$TOOL_INPUT" \\
720
+ --arg file_path "$FILE_PATH" \\
1134
721
  --arg content "$PROPOSED" \\
1135
722
  --arg file_before "$FILE_BEFORE" \\
1136
723
  --argjson diff "$DIFF_FIELD" \\
@@ -1139,12 +726,14 @@ BODY=$(jq -n \\
1139
726
  --arg session_id "$SESSION_ID" \\
1140
727
  --arg tool_use_id "$TOOL_USE_ID" \\
1141
728
  --arg cwd "$CWD" \\
1142
- --arg permission_mode "$PERMISSION_MODE" \\
1143
- --arg headless_flag "$HEADLESS_FLAG" \\
1144
729
  --arg repo "$GIT_REPO" \\
730
+ --arg permission_mode "$PERMISSION_MODE" \\
731
+ --arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
1145
732
  '{
1146
- file_path: $file_path,
733
+ hook_event: $hook_event,
1147
734
  tool_name: $tool_name,
735
+ tool_input: $tool_input,
736
+ file_path: $file_path,
1148
737
  content: $content,
1149
738
  file_before: (if ($file_before | length) > 0 then $file_before else null end),
1150
739
  diff: $diff,
@@ -1153,844 +742,190 @@ BODY=$(jq -n \\
1153
742
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1154
743
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1155
744
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
745
+ repo: (if ($repo | length) > 0 then $repo else null end),
1156
746
  permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
1157
- headless: ($headless_flag == "1"),
1158
- repo: (if ($repo | length) > 0 then $repo else null end)
747
+ headless: ($headless_flag == "1")
1159
748
  }')
1160
749
 
1161
- # Refresh JWT on 401 (mirrors the bash judge pattern).
1162
- refresh_jwt() {
1163
- local refresh_token
1164
- refresh_token=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
1165
- if [ -z "$refresh_token" ]; then return 1; fi
1166
- local resp
1167
- local refresh_body
1168
- refresh_body=$(jq -n --arg rt "$refresh_token" '{refresh_token:$rt}')
1169
- resp=$(curl -sS -X POST "\${GATEWAY_URL}/api/auth/refresh" \\
1170
- -H "Content-Type: application/json" \\
1171
- -d "$refresh_body" \\
1172
- --max-time 3 2>/dev/null)
1173
- local new_access
1174
- new_access=$(echo "$resp" | jq -r '.access_token // empty' 2>/dev/null)
1175
- if [ -z "$new_access" ]; then return 1; fi
1176
- local new_refresh
1177
- new_refresh=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
1178
- if [ -z "$new_refresh" ]; then new_refresh="$refresh_token"; fi
1179
- local tmp="\${CREDS_PATH}.synkro.tmp"
1180
- jq --arg at "$new_access" --arg rt "$new_refresh" \\
1181
- '. + {access_token: $at, refresh_token: $rt}' \\
1182
- "$CREDS_PATH" > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
1183
- JWT="$new_access"
1184
- return 0
1185
- }
1186
-
1187
- ensure_fresh_jwt() {
1188
- [ -z "$JWT" ] && return 1
1189
- local payload exp now remaining
1190
- payload=$(printf '%s' "$JWT" | cut -d. -f2)
1191
- case $((\${#payload} % 4)) in
1192
- 2) payload="\${payload}==" ;;
1193
- 3) payload="\${payload}=" ;;
1194
- esac
1195
- exp=$(printf '%s' "$payload" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
1196
- now=$(date -u +%s)
1197
- remaining=$((exp - now))
1198
- if [ "$remaining" -lt 60 ]; then
1199
- refresh_jwt
1200
- fi
1201
- }
1202
-
1203
- ensure_fresh_jwt
1204
-
1205
-
1206
- # Single fetch: /cli/hook-context returns me + rules in one call.
1207
- HOOK_CTX=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/hook-context" -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
1208
- SYNKRO_INFERENCE_TIER=$(echo "$HOOK_CTX" | jq -r '.tier // empty' 2>/dev/null)
1209
- SYNKRO_CAPTURE_DEPTH=$(echo "$HOOK_CTX" | jq -r '.capture_depth // empty' 2>/dev/null)
1210
- SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-fast}"
1211
- SYNKRO_CAPTURE_DEPTH="\${SYNKRO_CAPTURE_DEPTH:-local_only}"
1212
-
1213
- if synkro_channel_up || { [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; }; then
1214
- RULES_CACHE="$HOME/.synkro/.rules-cache-edit"
1215
- RULES_JQ='[.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id, text, severity, category, mode}]'
1216
- ORG_RULES=$(echo "$HOOK_CTX" | jq -c "$RULES_JQ" 2>/dev/null || echo "[]")
1217
- if [ -n "$ORG_RULES" ] && [ "$ORG_RULES" != "null" ] && [ "$ORG_RULES" != "[]" ]; then
1218
- printf '%s' "$ORG_RULES" > "$RULES_CACHE" 2>/dev/null || true
1219
- elif [ -f "$RULES_CACHE" ]; then
1220
- ORG_RULES=$(cat "$RULES_CACHE" 2>/dev/null || echo "[]")
1221
- fi
1222
- if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
1223
-
1224
- GRADER_PROMPT_FILE=$(mktemp -t synkro-grade.XXXXXX)
1225
- trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
1226
- printf 'File: %s\\n' "$FILE_PATH" > "$GRADER_PROMPT_FILE"
1227
- printf 'User intent: %s\\n' "\${USER_INTENT:-none stated}" >> "$GRADER_PROMPT_FILE"
1228
- printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
1229
- printf 'Diff:\\n' >> "$GRADER_PROMPT_FILE"
1230
- printf '%s\\n' "$PROPOSED" >> "$GRADER_PROMPT_FILE"
1231
-
1232
- ROUTE_TAG=""
1233
- if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
1234
- ROUTE_TAG="local-cc"
1235
- synkro_log "editGuard $FILE_SHORT \u2192 routing via local-cc"
1236
- CC_RESP=$(node "$SYNKRO_CLI_BIN" grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
1237
- elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
1238
- ROUTE_TAG="local-cc"
1239
- synkro_log "editGuard $FILE_SHORT \u2192 routing via local-cc (PATH)"
1240
- CC_RESP=$(synkro grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
1241
- else
1242
- ROUTE_TAG="cc-print"
1243
- CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
1244
- fi
1245
- SYNKRO_PREFIX="[synkro:\${ROUTE_TAG:-cloud}]"
1246
-
1247
- # Wrapper extraction (greedy \u2014 tolerates nested XML tags).
1248
- V_INNER=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
1249
-
1250
- # Parse XML tags from local primer. Defaults to ok=true so the server's
1251
- # literal_match audit path still runs even if the grader returned nothing.
1252
- LOCAL_OK="true"
1253
- LOCAL_VIOLATION_REASON=""
1254
- LOCAL_VIOLATION_RULE_ID=""
1255
- if [ -n "$V_INNER" ]; then
1256
- OK_TAG=$(printf '%s' "$V_INNER" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
1257
- [ -n "$OK_TAG" ] && LOCAL_OK="$OK_TAG"
1258
- if [ "$LOCAL_OK" = "false" ]; then
1259
- # Extract first <violation>...</violation> block via awk (RS=closing tag),
1260
- # then xtag for fields. Handles < in content + multi-violation correctly.
1261
- FIRST_V=$(printf '%s' "$V_INNER" | awk -v RS='</violation>' '/<violation>/{print; exit}')
1262
- LOCAL_VIOLATION_RULE_ID=$(printf '%s' "$FIRST_V" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
1263
- LOCAL_VIOLATION_REASON=$(printf '%s' "$FIRST_V" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
1264
- fi
1265
- fi
1266
- # Build a JSON shape that downstream / server code already understands. This
1267
- # keeps the existing /precheck-edit/local-verdict POST format unchanged so
1268
- # the server-side literal_match path still works in non-local_only mode.
1269
- VERDICT_JSON=$(jq -n \\
1270
- --arg ok "$LOCAL_OK" \\
1271
- --arg reason "$LOCAL_VIOLATION_REASON" \\
1272
- --arg rule_id "$LOCAL_VIOLATION_RULE_ID" \\
1273
- 'if $ok == "false" then
1274
- {ok: false, violations: [{rule_id: $rule_id, reason: $reason}]}
1275
- else
1276
- {ok: true, violations: []}
1277
- end')
1278
-
1279
- if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ]; then
1280
- # No file content / diff / intent / actions leave the device. Synthesize
1281
- # the hook response from the local verdict only.
1282
- if [ "$LOCAL_OK" = "false" ]; then
1283
- FIRST_REASON="\${LOCAL_VIOLATION_REASON:-policy violation}"
1284
- RULE_ID="\${LOCAL_VIOLATION_RULE_ID:-local_violation}"
1285
- if [ "$IS_HEADLESS" = "1" ]; then DEC="deny"; else DEC="ask"; fi
1286
- RESP=$(jq -n \\
1287
- --arg dec "$DEC" \\
1288
- --arg reason "[synkro] $RULE_ID: $FIRST_REASON" \\
1289
- '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: $dec, permissionDecisionReason: $reason, additionalContext: $reason }, reason: $reason }')
1290
- else
1291
- RESP=$(jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" }, reason: "" }')
1292
- fi
1293
- else
1294
- LOCAL_BODY=$(jq -n \\
1295
- --argjson verdict "$VERDICT_JSON" \\
1296
- --arg file_path "$FILE_PATH" \\
1297
- --arg tool_name "$TOOL_NAME" \\
1298
- --arg content "$PROPOSED" \\
1299
- --arg file_before "$FILE_BEFORE" \\
1300
- --argjson diff "$DIFF_FIELD" \\
1301
- --arg user_intent "$USER_INTENT" \\
1302
- --argjson recent_actions "$RECENT_ACTIONS" \\
1303
- --arg session_id "$SESSION_ID" \\
1304
- --arg tool_use_id "$TOOL_USE_ID" \\
1305
- --arg cwd "$CWD" \\
1306
- --arg permission_mode "$PERMISSION_MODE" \\
1307
- --arg headless_flag "$HEADLESS_FLAG" \\
1308
- --arg repo "$GIT_REPO" \\
1309
- '{
1310
- verdict: $verdict,
1311
- file_path: $file_path,
1312
- tool_name: $tool_name,
1313
- content: $content,
1314
- file_before: (if ($file_before | length) > 0 then $file_before else null end),
1315
- diff: $diff,
1316
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
1317
- recent_actions: $recent_actions,
1318
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1319
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1320
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1321
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
1322
- headless: ($headless_flag == "1"),
1323
- repo: (if ($repo | length) > 0 then $repo else null end)
1324
- }')
1325
-
1326
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit/local-verdict" \\
1327
- -H "Content-Type: application/json" \\
1328
- -H "Authorization: Bearer $JWT" \\
1329
- -d "$LOCAL_BODY" \\
1330
- --max-time 5 2>/dev/null || echo "")
1331
-
1332
- if echo "$RESP" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
1333
- if refresh_jwt; then
1334
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit/local-verdict" \\
1335
- -H "Content-Type: application/json" \\
1336
- -H "Authorization: Bearer $JWT" \\
1337
- -d "$LOCAL_BODY" \\
1338
- --max-time 5 2>/dev/null || echo "")
1339
- fi
1340
- fi
1341
- fi
1342
- else
1343
- # \u2500\u2500\u2500 Server-side grading. \u2500\u2500\u2500
1344
- SYNKRO_PREFIX="[synkro:cloud]"
1345
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit" \\
1346
- -H "Content-Type: application/json" \\
1347
- -H "Authorization: Bearer $JWT" \\
1348
- -d "$BODY" \\
1349
- --max-time 3 2>/dev/null || echo "")
1350
-
1351
- if echo "$RESP" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
1352
- if refresh_jwt; then
1353
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit" \\
1354
- -H "Content-Type: application/json" \\
1355
- -H "Authorization: Bearer $JWT" \\
1356
- -d "$BODY" \\
1357
- --max-time 3 2>/dev/null || echo "")
1358
- fi
1359
- fi
1360
- fi
750
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
1361
751
 
1362
752
  if [ -z "$RESP" ]; then
1363
753
  synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
1364
- jq -n --arg m "$SYNKRO_PREFIX editGuard $FILE_SHORT \u2192 error (timeout)" '{systemMessage: $m}'
754
+ echo '{}'
1365
755
  exit 0
1366
756
  fi
1367
757
 
1368
758
  if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1369
759
  synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
1370
- jq -n --arg m "$SYNKRO_PREFIX editGuard $FILE_SHORT \u2192 error (bad response)" '{systemMessage: $m}'
760
+ echo '{}'
1371
761
  exit 0
1372
762
  fi
1373
763
 
1374
- DECISION=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
1375
- if [ "$DECISION" = "deny" ]; then
1376
- DENY_REASON=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecisionReason // ""' 2>/dev/null)
1377
- synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED: $DENY_REASON"
1378
- echo "$RESP"
764
+ DECISION=$(echo "$RESP" | jq -r '.hook_response.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
765
+ if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
766
+ synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
767
+ echo "$RESP" | jq -c '.hook_response'
1379
768
  else
1380
- VERDICT_REASON=$(echo "$RESP" | jq -r '.reason // empty' 2>/dev/null)
1381
- if [ -n "$VERDICT_REASON" ]; then
1382
- synkro_log "editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON"
1383
- RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "$SYNKRO_PREFIX editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON" '. + {systemMessage: $m}')
769
+ REASON=$(echo "$RESP" | jq -r '.hook_response.reason // empty' 2>/dev/null)
770
+ if [ -n "$REASON" ]; then
771
+ synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
1384
772
  else
1385
773
  synkro_log "editGuard $FILE_SHORT \u2192 pass"
1386
- RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "$SYNKRO_PREFIX editGuard $FILE_SHORT \u2192 pass" '. + {systemMessage: $m}')
1387
- fi
1388
- echo "$RESP_WITH_MSG"
1389
- fi
1390
-
1391
- # Fire-and-forget anonymized telemetry for local_only mode
1392
- if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && [ -n "$DECISION" ]; then
1393
- LOCAL_VERDICT="allow"
1394
- LOCAL_SEVERITY="audit"
1395
- LOCAL_CATEGORY="edit_pass"
1396
- if [ "$DECISION" = "deny" ]; then
1397
- LOCAL_VERDICT="warn"
1398
- LOCAL_SEVERITY="block"
1399
- LOCAL_CATEGORY="edit_violation"
1400
774
  fi
1401
- (
1402
- MECH_CAT=""
1403
- BIZ_CAT=""
1404
- if [ "$LOCAL_VERDICT" = "warn" ]; then
1405
- CLASS_CACHE="$HOME/.synkro/.classification-prompt"
1406
- CLASS_PROMPT=""
1407
- if [ -f "$CLASS_CACHE" ] && find "$CLASS_CACHE" -mmin -1440 2>/dev/null | grep -q .; then
1408
- CLASS_PROMPT=$(cat "$CLASS_CACHE" 2>/dev/null)
1409
- else
1410
- CLASS_PROMPT=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/judge-prompts" \\
1411
- -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null | jq -r '.classification_prompt // empty')
1412
- [ -n "$CLASS_PROMPT" ] && echo "$CLASS_PROMPT" > "$CLASS_CACHE"
1413
- fi
1414
- if [ -n "$CLASS_PROMPT" ]; then
1415
- CLASS_INPUT=$(printf '%s\\n\\nViolation context:\\n- Tool: %s\\n- Category: %s\\n- Severity: %s\\n- Hook type: edit pre-check judge' "$CLASS_PROMPT" "$TOOL_NAME" "$LOCAL_CATEGORY" "$LOCAL_SEVERITY")
1416
- CLASS_RESP=$(echo "$CLASS_INPUT" | claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null || echo "")
1417
- MECH_CAT=$(echo "$CLASS_RESP" | grep -oE '<mechanism>[^<]+</mechanism>' | sed 's/<[^>]*>//g')
1418
- BIZ_CAT=$(echo "$CLASS_RESP" | grep -oE '<business>[^<]+</business>' | sed 's/<[^>]*>//g')
1419
- fi
1420
- fi
1421
- ANON_BODY=$(jq -n \\
1422
- --arg event_id "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
1423
- --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
1424
- --arg hook_type "edit" \\
1425
- --arg verdict "$LOCAL_VERDICT" \\
1426
- --arg severity "$LOCAL_SEVERITY" \\
1427
- --arg category "$LOCAL_CATEGORY" \\
1428
- --arg model "claude-sonnet-4-6" \\
1429
- --arg tool_name "$TOOL_NAME" \\
1430
- --arg repo "\${GIT_REPO:-}" \\
1431
- --arg session_id "$SESSION_ID" \\
1432
- --arg mech_cat "$MECH_CAT" \\
1433
- --arg biz_cat "$BIZ_CAT" \\
1434
- '{
1435
- event_id: $event_id,
1436
- timestamp: $timestamp,
1437
- hook_type: $hook_type,
1438
- verdict: $verdict,
1439
- severity: $severity,
1440
- category: $category,
1441
- model: $model,
1442
- tool_name: $tool_name
1443
- } + (if $repo != "" then {repo: $repo} else {} end)
1444
- + (if $session_id != "" then {session_id: $session_id} else {} end)
1445
- + (if $mech_cat != "" then {mechanism_category: $mech_cat} else {} end)
1446
- + (if $biz_cat != "" then {business_category: $biz_cat} else {} end)')
1447
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
1448
- -H "Content-Type: application/json" \\
1449
- -H "Authorization: Bearer $JWT" \\
1450
- -d "$ANON_BODY" \\
1451
- --max-time 2 >/dev/null 2>&1
1452
- ) &
775
+ echo "$RESP" | jq -c '.hook_response // {}'
1453
776
  fi
1454
-
1455
777
  exit 0
1456
778
  `;
1457
779
  CC_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
1458
- # Synkro PostToolUse Edit/Write afterFileEdit hook.
1459
- # On ok=false, emits a system message that surfaces inline in CC.
1460
- # Always exits 0 with valid JSON \u2014 never breaks CC's flow.
1461
-
1462
- synkro_log() { echo "[synkro] $1" >&2; }
1463
-
1464
- # True if anything is listening on the local-cc channel TCP port.
1465
- synkro_channel_up() {
1466
- (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
1467
- }
1468
-
1469
- CONFIG_FILE="$HOME/.synkro/config.env"
1470
- if [ -f "$CONFIG_FILE" ]; then
1471
- set -a
1472
- # shellcheck disable=SC1090
1473
- . "$CONFIG_FILE"
1474
- set +a
1475
- fi
1476
-
1477
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
1478
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
780
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
781
+ . "$SCRIPT_DIR/_synkro-common.sh"
1479
782
 
1480
- if [ ! -f "$CREDS_PATH" ]; then
1481
- echo '{}'
1482
- exit 0
1483
- fi
1484
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null || true)
1485
- if [ -z "$JWT" ]; then
1486
- echo '{}'
1487
- exit 0
1488
- fi
783
+ JWT=$(synkro_load_jwt)
784
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
785
+ synkro_ensure_fresh_jwt
1489
786
 
1490
787
  PAYLOAD=$(cat)
1491
- if [ -z "$PAYLOAD" ]; then
1492
- echo '{}'
1493
- exit 0
1494
- fi
788
+ if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1495
789
 
1496
790
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1497
- case "$TOOL_NAME" in
1498
- Edit|Write|MultiEdit|NotebookEdit) ;;
1499
- *) echo '{}'; exit 0 ;;
1500
- esac
791
+ case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1501
792
 
1502
793
  TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1503
794
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1504
795
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1505
796
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1506
- # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
1507
- GIT_REPO=""
1508
- if command -v git >/dev/null 2>&1; then
1509
- _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
1510
- if [ -n "$_REMOTE" ]; then
1511
- GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
1512
- fi
797
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
798
+
799
+ # Correction followup (backgrounded)
800
+ if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
801
+ (
802
+ 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"}')
803
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
804
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
805
+ -d "$BODY" --max-time 2 >/dev/null 2>&1
806
+ ) &
1513
807
  fi
1514
808
 
809
+ # Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
1515
810
  FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1516
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
1517
- echo '{}'
1518
- exit 0
1519
- fi
811
+ if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1520
812
 
1521
813
  BASENAME=$(basename "$FILE_PATH")
1522
- synkro_log "editScan checking: $BASENAME"
814
+ synkro_log "editScan: $BASENAME"
1523
815
 
1524
- # Read post-edit file content (cap 64KB).
1525
816
  FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1526
- if [ -z "$FILE_CONTENT" ]; then
1527
- echo '{}'
1528
- exit 0
1529
- fi
817
+ if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
1530
818
 
1531
819
  DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null || echo "null")
1532
- if [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ]; then
1533
- DIFF_FIELD="null"
1534
- fi
820
+ [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
1535
821
 
1536
- # Resolve dependency versions + CVE config from nearest package.json / .synkro.json
1537
822
  DEPS_JSON="{}"
1538
- CVE_ALLOWLIST="[]"
1539
- CVE_MIN_SEVERITY="null"
1540
823
  _PKG_DIR=$(dirname "$FILE_PATH")
1541
824
  while [ "$_PKG_DIR" != "/" ]; do
1542
825
  if [ -f "$_PKG_DIR/package.json" ]; then
1543
- DEPS_JSON=$(jq -s '.[0] * .[1]' \\
1544
- <(jq '.dependencies // {}' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}") \\
1545
- <(jq '.devDependencies // {}' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}") 2>/dev/null || echo "{}")
1546
- if [ -f "$_PKG_DIR/.synkro.json" ]; then
1547
- CVE_ALLOWLIST=$(jq '.cve_allowlist // []' "$_PKG_DIR/.synkro.json" 2>/dev/null || echo "[]")
1548
- CVE_MIN_SEVERITY=$(jq '.cve_min_severity // null' "$_PKG_DIR/.synkro.json" 2>/dev/null || echo "null")
1549
- fi
826
+ DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
1550
827
  break
1551
828
  fi
1552
829
  _PKG_DIR=$(dirname "$_PKG_DIR")
1553
830
  done
1554
831
 
1555
832
  BODY=$(jq -n \\
833
+ --arg hook_event "PostToolUse" \\
834
+ --arg tool_name "$TOOL_NAME" \\
835
+ --argjson tool_input "$TOOL_INPUT" \\
1556
836
  --arg file_path "$FILE_PATH" \\
1557
837
  --arg content "$FILE_CONTENT" \\
1558
838
  --argjson diff "$DIFF_FIELD" \\
1559
- --argjson deps "$DEPS_JSON" \\
1560
- --argjson cve_allowlist "$CVE_ALLOWLIST" \\
1561
- --argjson cve_min_severity "$CVE_MIN_SEVERITY" \\
839
+ --argjson dependencies "$DEPS_JSON" \\
1562
840
  --arg session_id "$SESSION_ID" \\
1563
841
  --arg tool_use_id "$TOOL_USE_ID" \\
1564
842
  --arg cwd "$CWD" \\
1565
843
  --arg repo "$GIT_REPO" \\
1566
844
  '{
845
+ hook_event: $hook_event,
846
+ tool_name: $tool_name,
847
+ tool_input: $tool_input,
1567
848
  file_path: $file_path,
1568
849
  content: $content,
1569
850
  diff: $diff,
1570
- dependencies: $deps,
1571
- cve_allowlist: $cve_allowlist,
1572
- cve_min_severity: $cve_min_severity,
851
+ dependencies: $dependencies,
1573
852
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1574
853
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1575
854
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
1576
855
  repo: (if ($repo | length) > 0 then $repo else null end)
1577
- }' 2>/dev/null || true)
1578
-
1579
- if [ -z "$BODY" ] || ! echo "$BODY" | jq -e 'type == "object"' >/dev/null 2>&1; then
1580
- jq -n --arg m "[synkro] editScan $BASENAME \u2192 error (body construction failed)" '{systemMessage: $m}'
1581
- exit 0
1582
- fi
1583
-
1584
- refresh_jwt() {
1585
- local refresh_token
1586
- refresh_token=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
1587
- if [ -z "$refresh_token" ]; then return 1; fi
1588
- local resp
1589
- local refresh_body
1590
- refresh_body=$(jq -n --arg rt "$refresh_token" '{refresh_token:$rt}')
1591
- resp=$(curl -sS -X POST "\${GATEWAY_URL}/api/auth/refresh" \\
1592
- -H "Content-Type: application/json" \\
1593
- -d "$refresh_body" \\
1594
- --max-time 3 2>/dev/null)
1595
- local new_access
1596
- new_access=$(echo "$resp" | jq -r '.access_token // empty' 2>/dev/null)
1597
- if [ -z "$new_access" ]; then return 1; fi
1598
- local new_refresh
1599
- new_refresh=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
1600
- if [ -z "$new_refresh" ]; then new_refresh="$refresh_token"; fi
1601
- local tmp="\${CREDS_PATH}.synkro.tmp"
1602
- jq --arg at "$new_access" --arg rt "$new_refresh" \\
1603
- '. + {access_token: $at, refresh_token: $rt}' \\
1604
- "$CREDS_PATH" > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
1605
- JWT="$new_access"
1606
- return 0
1607
- }
1608
-
1609
- ensure_fresh_jwt() {
1610
- [ -z "$JWT" ] && return 1
1611
- local payload exp now remaining
1612
- payload=$(printf '%s' "$JWT" | cut -d. -f2)
1613
- case $((\${#payload} % 4)) in
1614
- 2) payload="\${payload}==" ;;
1615
- 3) payload="\${payload}=" ;;
1616
- esac
1617
- exp=$(printf '%s' "$payload" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
1618
- now=$(date -u +%s)
1619
- remaining=$((exp - now))
1620
- if [ "$remaining" -lt 60 ]; then
1621
- refresh_jwt
1622
- fi
1623
- }
1624
-
1625
- ensure_fresh_jwt
1626
-
1627
- # Fire-and-forget correction-followup (backgrounded \u2014 must not block grading).
1628
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
1629
- (
1630
- FOLLOWUP_BODY=$(jq -n \\
1631
- --arg sid "$SESSION_ID" \\
1632
- --arg tid "$TOOL_USE_ID" \\
1633
- '{session_id: $sid, tool_use_id: $tid, decision: "allow"}')
1634
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit/correction-followup" \\
1635
- -H "Content-Type: application/json" \\
1636
- -H "Authorization: Bearer $JWT" \\
1637
- -d "$FOLLOWUP_BODY" \\
1638
- --max-time 2 \\
1639
- >/dev/null 2>&1
1640
- ) &
1641
- fi
1642
-
1643
- # Single fetch: /cli/hook-context returns me + rules in one call.
1644
- HOOK_CTX=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/hook-context" -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
1645
- SYNKRO_INFERENCE_TIER=$(echo "$HOOK_CTX" | jq -r '.tier // empty' 2>/dev/null)
1646
- SYNKRO_CAPTURE_DEPTH=$(echo "$HOOK_CTX" | jq -r '.capture_depth // empty' 2>/dev/null)
1647
- SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-fast}"
1648
- SYNKRO_CAPTURE_DEPTH="\${SYNKRO_CAPTURE_DEPTH:-local_only}"
1649
-
1650
- if synkro_channel_up || { [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; }; then
1651
- RULES_CACHE="$HOME/.synkro/.rules-cache-edit-capture"
1652
- ORG_RULES=$(echo "$HOOK_CTX" | jq -c '[.rules[]? | select(.hook_stage == "post" or .hook_stage == "both" or .hook_stage == null) | {rule_id, text, severity, category}]' 2>/dev/null || echo "[]")
1653
- if [ -n "$ORG_RULES" ] && [ "$ORG_RULES" != "null" ] && [ "$ORG_RULES" != "[]" ]; then
1654
- printf '%s' "$ORG_RULES" > "$RULES_CACHE" 2>/dev/null || true
1655
- elif [ -f "$RULES_CACHE" ]; then
1656
- ORG_RULES=$(cat "$RULES_CACHE" 2>/dev/null || echo "[]")
1657
- fi
1658
- if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
1659
-
1660
- # Extract CVE config from hook-context response (allowlist + min_severity)
1661
- CVE_ALLOWLIST=$(echo "$HOOK_CTX" | jq -c '.cve_config.allowlist // []' 2>/dev/null || echo "[]")
1662
- CVE_MIN_SEVERITY=$(echo "$HOOK_CTX" | jq '.cve_config.min_severity // null' 2>/dev/null || echo "null")
1663
-
1664
- # CVE scan \u2014 runs server-side in parallel with local LLM grading
1665
- CVE_RESULT_FILE=$(mktemp -t synkro-cve.XXXXXX)
1666
- (
1667
- CVE_BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" --argjson deps "$DEPS_JSON" --argjson al "$CVE_ALLOWLIST" --argjson ms "$CVE_MIN_SEVERITY" '{file_path: $fp, content: $c, dependencies: $deps, cve_allowlist: $al, cve_min_severity: $ms}')
1668
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/cve-scan" \\
1669
- -H "Content-Type: application/json" \\
1670
- -H "Authorization: Bearer $JWT" \\
1671
- -d "$CVE_BODY" --max-time 4 2>/dev/null > "$CVE_RESULT_FILE"
1672
- ) &
1673
- CVE_PID=$!
1674
-
1675
- GRADER_PROMPT_FILE=$(mktemp -t synkro-edit-capture.XXXXXX)
1676
- trap "rm -f \\"$GRADER_PROMPT_FILE\\" \\"$CVE_RESULT_FILE\\"" EXIT
1677
- printf 'File: %s\\n' "$FILE_PATH" > "$GRADER_PROMPT_FILE"
1678
- printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
1679
- printf 'Content:\\n' >> "$GRADER_PROMPT_FILE"
1680
- printf '%s\\n' "$FILE_CONTENT" >> "$GRADER_PROMPT_FILE"
1681
-
1682
- ROUTE_TAG=""
1683
- if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
1684
- ROUTE_TAG="local-cc"
1685
- synkro_log "editGuard $BASENAME \u2192 routing via local-cc"
1686
- CC_RESP=$(node "$SYNKRO_CLI_BIN" grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
1687
- elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
1688
- ROUTE_TAG="local-cc"
1689
- synkro_log "editGuard $BASENAME \u2192 routing via local-cc (PATH)"
1690
- CC_RESP=$(synkro grade edit < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
1691
- else
1692
- ROUTE_TAG="cc-print"
1693
- CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
1694
- fi
1695
- SYNKRO_PREFIX="[synkro:\${ROUTE_TAG:-cloud}]"
1696
-
1697
- # Wait for CVE scan
1698
- wait $CVE_PID 2>/dev/null
1699
- CVE_TEXT=""
1700
- CVE_FINDINGS_JSON="[]"
1701
- if [ -s "$CVE_RESULT_FILE" ]; then
1702
- # Only flag CVEs for packages introduced by THIS edit, not pre-existing imports.
1703
- # Extract new_string from the diff \u2014 if the import existed in old_string too, skip it.
1704
- EDIT_NEW=$(echo "$DIFF_FIELD" | jq -r '.new_string // empty' 2>/dev/null)
1705
- EDIT_OLD=$(echo "$DIFF_FIELD" | jq -r '.old_string // empty' 2>/dev/null)
1706
- if [ -n "$EDIT_NEW" ] && [ "$DIFF_FIELD" != "null" ]; then
1707
- CVE_FINDINGS_JSON=$(jq -c --arg new_s "$EDIT_NEW" --arg old_s "$EDIT_OLD" '
1708
- [.findings[]? | . as $f |
1709
- select(
1710
- ($new_s | test($f.package; "i")) and
1711
- (($old_s | length) == 0 or ($old_s | test($f.package; "i") | not))
1712
- ) | {package, version, cve: .id, severity, score}]
1713
- ' "$CVE_RESULT_FILE" 2>/dev/null || echo "[]")
1714
- else
1715
- CVE_FINDINGS_JSON=$(jq -c '[.findings[]? | {package: .package, version: .version, cve: .id, severity: .severity, score: .score}]' "$CVE_RESULT_FILE" 2>/dev/null || echo "[]")
1716
- fi
1717
- # Regenerate summary from filtered findings only
1718
- if [ "$CVE_FINDINGS_JSON" != "[]" ] && [ -n "$CVE_FINDINGS_JSON" ]; then
1719
- CVE_TEXT=$(echo "$CVE_FINDINGS_JSON" | jq -r '
1720
- group_by(.package) | map(
1721
- (.[0].package) + " (" + (length | tostring) + " CVEs, max CVSS " +
1722
- ([.[].score // 0] | max | tostring) + ": " +
1723
- ([.[].cve] | join(", ")) + ")"
1724
- ) | join("; ")
1725
- ' 2>/dev/null || echo "")
1726
- fi
1727
- fi
1728
-
1729
- # Wrapper extraction (greedy \u2014 tolerates nested XML tags).
1730
- V_INNER=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
1731
- if [ -n "$V_INNER" ]; then
1732
- LOCAL_OK=$(printf '%s' "$V_INNER" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
1733
- LOCAL_OK="\${LOCAL_OK:-true}"
1734
- # Top-level <reason> (clean diff). Skip text inside <violation>...</violation>
1735
- # by stripping those blocks first so the regex doesn't grab a violation reason.
1736
- OUTER_V=$(printf '%s' "$V_INNER" | sed -E 's|<violation>[^<]*(<[^/]+>[^<]*</[^>]+>[^<]*)*</violation>||g')
1737
- OUTER_REASON=$(printf '%s' "$OUTER_V" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
1738
- # First violation block fields (when ok=false).
1739
- FIRST_V=$(printf '%s' "$V_INNER" | awk -v RS='</violation>' '/<violation>/{print; exit}')
1740
- LOCAL_SEV=$(printf '%s' "$FIRST_V" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
1741
- LOCAL_CAT=$(printf '%s' "$FIRST_V" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
1742
- LOCAL_REASON=$(printf '%s' "$FIRST_V" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
1743
- # Merge CVE findings
1744
- if [ -n "$CVE_TEXT" ]; then
1745
- if [ "$LOCAL_OK" = "false" ]; then
1746
- LOCAL_REASON="\${LOCAL_REASON}. Vulnerable dependencies: \${CVE_TEXT}"
1747
- else
1748
- LOCAL_OK="false"
1749
- LOCAL_SEV="block"
1750
- LOCAL_CAT="vulnerable_dependency"
1751
- LOCAL_REASON="Vulnerable dependencies detected: \${CVE_TEXT}"
1752
- fi
1753
- fi
1754
- # Convert to JSON shape downstream code expects.
1755
- RESP=$(jq -n \\
1756
- --arg ok "$LOCAL_OK" \\
1757
- --arg sev "\${LOCAL_SEV:-low}" \\
1758
- --arg cat "\${LOCAL_CAT:-unspecified}" \\
1759
- --arg reason "$LOCAL_REASON" \\
1760
- --arg outer_reason "$OUTER_REASON" \\
1761
- 'if $ok == "false" then
1762
- {ok: false, severity: $sev, category: $cat, reason: $reason}
1763
- else
1764
- {ok: true, severity: "low", category: "clean", reason: $outer_reason}
1765
- end')
1766
- else
1767
- RESP=""
1768
- if [ -n "$CVE_TEXT" ]; then
1769
- RESP=$(jq -n --arg reason "Vulnerable dependencies detected: $CVE_TEXT" \\
1770
- '{ok: false, severity: "block", category: "vulnerable_dependency", reason: $reason}')
1771
- fi
1772
- fi
1773
- else
1774
- # \u2500\u2500\u2500 Server-side grading. \u2500\u2500\u2500
1775
- SYNKRO_PREFIX="[synkro:cloud]"
1776
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge-edit" \\
1777
- -H "Content-Type: application/json" \\
1778
- -H "Authorization: Bearer $JWT" \\
1779
- -d "$BODY" \\
1780
- --max-time 12 2>/dev/null || echo "")
856
+ }')
1781
857
 
1782
- if echo "$RESP" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
1783
- if refresh_jwt; then
1784
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge-edit" \\
1785
- -H "Content-Type: application/json" \\
1786
- -H "Authorization: Bearer $JWT" \\
1787
- -d "$BODY" \\
1788
- --max-time 12 2>/dev/null || echo "")
1789
- fi
1790
- fi
1791
- fi
858
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
1792
859
 
1793
860
  if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1794
861
  synkro_log "editScan $BASENAME \u2192 error (no response)"
1795
- jq -n --arg m "$SYNKRO_PREFIX editScan $BASENAME \u2192 error (no response)" '{systemMessage: $m}'
1796
- exit 0
1797
- fi
1798
-
1799
- OK=$(echo "$RESP" | jq -r 'if .ok == false then "false" else "true" end' 2>/dev/null)
1800
- SEVERITY=$(echo "$RESP" | jq -r '.severity // "low"' 2>/dev/null)
1801
- CATEGORY=$(echo "$RESP" | jq -r '.category // "unspecified"' 2>/dev/null)
1802
- REASON=$(echo "$RESP" | jq -r '.reason // ""' 2>/dev/null)
1803
-
1804
- # Fire-and-forget anonymized telemetry for local_only mode (post-edit grading verdict).
1805
- if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ]; then
1806
- if [ "$OK" = "false" ]; then
1807
- LOCAL_VERDICT="warn"; LOCAL_SEVERITY="block"; LOCAL_RISK="high"
1808
- else
1809
- LOCAL_VERDICT="allow"; LOCAL_SEVERITY="audit"; LOCAL_RISK="low"
1810
- fi
1811
- (
1812
- MECH_CAT=""
1813
- BIZ_CAT=""
1814
- if [ "$LOCAL_VERDICT" = "warn" ]; then
1815
- CLASS_CACHE="$HOME/.synkro/.classification-prompt"
1816
- CLASS_PROMPT=""
1817
- if [ -f "$CLASS_CACHE" ] && find "$CLASS_CACHE" -mmin -1440 2>/dev/null | grep -q .; then
1818
- CLASS_PROMPT=$(cat "$CLASS_CACHE" 2>/dev/null)
1819
- else
1820
- CLASS_PROMPT=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/judge-prompts" \\
1821
- -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null | jq -r '.classification_prompt // empty')
1822
- [ -n "$CLASS_PROMPT" ] && echo "$CLASS_PROMPT" > "$CLASS_CACHE"
1823
- fi
1824
- if [ -n "$CLASS_PROMPT" ]; then
1825
- CLASS_INPUT=$(printf '%s\\n\\nViolation context:\\n- Tool: %s\\n- Category: %s\\n- Severity: %s\\n- Hook type: post-edit capture grader' "$CLASS_PROMPT" "$TOOL_NAME" "$CATEGORY" "$LOCAL_SEVERITY")
1826
- CLASS_RESP=$(echo "$CLASS_INPUT" | claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null || echo "")
1827
- MECH_CAT=$(echo "$CLASS_RESP" | grep -oE '<mechanism>[^<]+</mechanism>' | sed 's/<[^>]*>//g')
1828
- BIZ_CAT=$(echo "$CLASS_RESP" | grep -oE '<business>[^<]+</business>' | sed 's/<[^>]*>//g')
1829
- fi
1830
- fi
1831
- ANON_BODY=$(jq -n \\
1832
- --arg event_id "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
1833
- --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
1834
- --arg hook_type "edit_capture" \\
1835
- --arg verdict "$LOCAL_VERDICT" \\
1836
- --arg severity "$LOCAL_SEVERITY" \\
1837
- --arg risk_level "$LOCAL_RISK" \\
1838
- --arg category "$CATEGORY" \\
1839
- --arg model "claude-sonnet-4-6" \\
1840
- --arg tool_name "$TOOL_NAME" \\
1841
- --arg repo "\${GIT_REPO:-}" \\
1842
- --arg session_id "$SESSION_ID" \\
1843
- --arg mech_cat "$MECH_CAT" \\
1844
- --arg biz_cat "$BIZ_CAT" \\
1845
- --argjson cve_findings "\${CVE_FINDINGS_JSON:-[]}" \\
1846
- '{
1847
- event_id: $event_id, timestamp: $timestamp, hook_type: $hook_type,
1848
- verdict: $verdict, severity: $severity, risk_level: $risk_level,
1849
- category: $category, model: $model, tool_name: $tool_name
1850
- } + (if $repo != "" then {repo: $repo} else {} end)
1851
- + (if $session_id != "" then {session_id: $session_id} else {} end)
1852
- + (if $mech_cat != "" then {mechanism_category: $mech_cat} else {} end)
1853
- + (if $biz_cat != "" then {business_category: $biz_cat} else {} end)
1854
- + (if ($cve_findings | length) > 0 then {cve_findings: $cve_findings} else {} end)')
1855
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
1856
- -H "Content-Type: application/json" \\
1857
- -H "Authorization: Bearer $JWT" \\
1858
- -d "$ANON_BODY" --max-time 2 >/dev/null 2>&1
1859
- ) &
1860
- fi
1861
-
1862
- if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
1863
- synkro_log "editScan $BASENAME \u2192 FAIL ($CATEGORY): $REASON"
1864
- SYS_MSG="$SYNKRO_PREFIX editScan $BASENAME \u2192 FAIL: \${REASON}"
1865
- ADDITIONAL_CTX="Synkro post-edit grader flagged \${BASENAME} (severity: \${SEVERITY}, category: \${CATEGORY}, route: \${ROUTE_TAG:-cloud}). Re-edit the file applying the retry guidance: \${REASON}"
1866
- jq -n \\
1867
- --arg sys_msg "$SYS_MSG" \\
1868
- --arg ctx "$ADDITIONAL_CTX" \\
1869
- '{
1870
- systemMessage: $sys_msg,
1871
- hookSpecificOutput: {
1872
- hookEventName: "PostToolUse",
1873
- additionalContext: $ctx
1874
- }
1875
- }'
862
+ echo '{}'
1876
863
  exit 0
1877
864
  fi
1878
865
 
1879
- if [ -n "$REASON" ]; then
1880
- synkro_log "editScan $BASENAME \u2192 pass ($CATEGORY): $REASON"
1881
- jq -n --arg m "$SYNKRO_PREFIX editScan $BASENAME \u2192 pass ($CATEGORY): $REASON" '{systemMessage: $m}'
866
+ # Server returns {hook_response: {...}} \u2014 extract and echo
867
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
868
+ echo "$RESP" | jq -c '.hook_response'
1882
869
  else
1883
- synkro_log "editScan $BASENAME \u2192 pass"
1884
- jq -n --arg m "$SYNKRO_PREFIX editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
870
+ echo '{}'
1885
871
  fi
1886
872
  exit 0
1887
873
  `;
1888
874
  CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
1889
- # Synkro Stop hook \u2014 emits "[synkro] stop \u2192 N findings: X auto-fixed, Y open"
1890
- # as a final summary line when the CC session ends. Reads guard_checks rows
1891
- # for the session via /api/v1/cli/session-summary.
1892
- # No set -e: hook must ALWAYS produce JSON output. Silent death = CC timeout.
1893
-
1894
- CONFIG_FILE="$HOME/.synkro/config.env"
1895
- if [ -f "$CONFIG_FILE" ]; then
1896
- set -a
1897
- # shellcheck disable=SC1090
1898
- . "$CONFIG_FILE"
1899
- set +a
1900
- fi
1901
-
1902
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
1903
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
875
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
876
+ . "$SCRIPT_DIR/_synkro-common.sh"
1904
877
 
1905
- if [ ! -f "$CREDS_PATH" ]; then
1906
- echo '{}'
1907
- exit 0
1908
- fi
1909
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
1910
- if [ -z "$JWT" ]; then
1911
- echo '{}'
1912
- exit 0
1913
- fi
878
+ JWT=$(synkro_load_jwt)
879
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1914
880
 
1915
881
  PAYLOAD=$(cat)
1916
882
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1917
- if [ -z "$SESSION_ID" ]; then
1918
- echo '{}'
1919
- exit 0
1920
- fi
883
+ if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
1921
884
 
1922
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1923
885
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
886
+ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
887
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1924
888
 
1925
- GIT_REPO=""
1926
- if command -v git >/dev/null 2>&1; then
1927
- _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
1928
- if [ -n "$_REMOTE" ]; then
1929
- GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
1930
- fi
1931
- fi
1932
-
1933
- # Fire-and-forget usage telemetry \u2014 runs every turn via Stop hook
889
+ # Fire-and-forget usage telemetry
1934
890
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1935
891
  (
1936
- _LAST_ASSISTANT=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1937
- if [ -n "$_LAST_ASSISTANT" ]; then
1938
- CC_MODEL=$(echo "$_LAST_ASSISTANT" | jq -r '.message.model // empty' 2>/dev/null)
1939
- CC_USAGE=$(echo "$_LAST_ASSISTANT" | jq -c '{
1940
- input_tokens: .message.usage.input_tokens,
1941
- output_tokens: .message.usage.output_tokens,
1942
- cache_creation_input_tokens: .message.usage.cache_creation_input_tokens,
1943
- cache_read_input_tokens: .message.usage.cache_read_input_tokens
1944
- }' 2>/dev/null || echo "{}")
892
+ _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
893
+ if [ -n "$_LAST" ]; then
894
+ CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
895
+ 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 "{}")
1945
896
  HAS_TOKENS=$(echo "$CC_USAGE" | jq '(.input_tokens // 0) + (.output_tokens // 0)' 2>/dev/null)
1946
897
  if [ -n "$HAS_TOKENS" ] && [ "$HAS_TOKENS" != "0" ]; then
1947
- USAGE_BODY=$(jq -n \\
898
+ BODY=$(jq -n \\
1948
899
  --arg event_id "usage_$(date +%s)_$$" \\
1949
- --arg hook_type "stop" \\
1950
- --arg verdict "allow" \\
1951
- --arg severity "none" \\
900
+ --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1952
901
  --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
1953
902
  --arg cc_model "\${CC_MODEL:-}" \\
1954
- --arg repo "\${GIT_REPO:-}" \\
1955
- --arg session_id "$SESSION_ID" \\
903
+ --arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
1956
904
  --argjson cc_usage "$CC_USAGE" \\
1957
- '{
1958
- event_id: $event_id, hook_type: $hook_type,
1959
- verdict: $verdict, severity: $severity,
1960
- model: $model, cc_usage: $cc_usage
1961
- } + (if $repo != "" then {repo: $repo} else {} end)
1962
- + (if $session_id != "" then {session_id: $session_id} else {} end)
1963
- + (if $cc_model != "" then {cc_model: $cc_model} else {} end)')
1964
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/local-verdict" \\
1965
- -H "Content-Type: application/json" \\
1966
- -H "Authorization: Bearer $JWT" \\
1967
- -d "$USAGE_BODY" --max-time 2 >/dev/null 2>&1
905
+ '{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
906
+ + (if $repo != "" then {repo:$repo} else {} end)
907
+ + (if $session_id != "" then {session_id:$session_id} else {} end)
908
+ + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
909
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
910
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
911
+ -d "$BODY" --max-time 2 >/dev/null 2>&1
1968
912
  fi
1969
913
  fi
1970
914
  ) &
1971
915
  fi
1972
916
 
1973
- # Tight timeout \u2014 the user already finished their session, don't make them wait.
1974
917
  RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-summary" \\
1975
918
  --data-urlencode "session_id=$SESSION_ID" \\
1976
- -H "Authorization: Bearer $JWT" \\
1977
- --max-time 2 2>/dev/null || echo "")
919
+ -H "Authorization: Bearer $JWT" --max-time 2 2>/dev/null || echo "")
1978
920
 
1979
- if [ -z "$RESP" ]; then
1980
- echo '{}'
1981
- exit 0
1982
- fi
921
+ if [ -z "$RESP" ]; then echo '{}'; exit 0; fi
1983
922
 
1984
923
  EDITS=$(echo "$RESP" | jq -r '.edits_scanned // 0' 2>/dev/null)
1985
924
  FINDINGS=$(echo "$RESP" | jq -r '.findings // 0' 2>/dev/null)
1986
925
  AUTO_FIXED=$(echo "$RESP" | jq -r '.auto_fixed // 0' 2>/dev/null)
1987
926
  OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
1988
927
 
1989
- # Stay silent if the session never touched files we scanned.
1990
- if [ "$EDITS" = "0" ] || [ -z "$EDITS" ]; then
1991
- echo '{}'
1992
- exit 0
1993
- fi
928
+ if [ "$EDITS" = "0" ] || [ -z "$EDITS" ]; then echo '{}'; exit 0; fi
1994
929
 
1995
930
  if [ "$FINDINGS" = "0" ] || [ -z "$FINDINGS" ]; then
1996
931
  SYS_MSG="[synkro] stop \u2192 0 issues across \${EDITS} edit(s), session complete"
@@ -1998,29 +933,16 @@ else
1998
933
  SYS_MSG="[synkro] stop \u2192 \${FINDINGS} finding(s): \${AUTO_FIXED} auto-fixed, \${OPEN} open"
1999
934
  fi
2000
935
 
2001
- jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
936
+ jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
2002
937
  exit 0
2003
938
  `;
2004
939
  CC_SESSION_START_SCRIPT = `#!/bin/bash
2005
- # Synkro SessionStart hook \u2014 when the user opens a fresh CC session in a repo
2006
- # we have findings on, surface "[synkro] session start \u2192 N open finding(s) in
2007
- # this repo" so they have context. Silent when there's nothing to report.
2008
- # No set -e: hook must ALWAYS produce JSON output. Silent death = CC timeout.
2009
-
2010
- CONFIG_FILE="$HOME/.synkro/config.env"
2011
- if [ -f "$CONFIG_FILE" ]; then
2012
- set -a
2013
- # shellcheck disable=SC1090
2014
- . "$CONFIG_FILE"
2015
- set +a
2016
- fi
940
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
941
+ . "$SCRIPT_DIR/_synkro-common.sh"
2017
942
 
2018
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
2019
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
943
+ JWT=$(synkro_load_jwt)
2020
944
 
2021
- # Route preamble \u2014 tell the user (and CC) which inference path will be used.
2022
- # We probe the local-cc TCP listener directly so the line reflects ground truth
2023
- # rather than just the persisted toggle.
945
+ # Route preamble
2024
946
  SYNKRO_PORT="\${SYNKRO_CHANNEL_PORT:-8929}"
2025
947
  if (exec 3<>/dev/tcp/127.0.0.1/"$SYNKRO_PORT") 2>/dev/null; then
2026
948
  exec 3<&- 3>&- 2>/dev/null || true
@@ -2029,78 +951,45 @@ else
2029
951
  ROUTE_LINE="[synkro] inference: cloud (local-cc channel not reachable)"
2030
952
  fi
2031
953
 
2032
- if [ ! -f "$CREDS_PATH" ]; then
2033
- jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
2034
- exit 0
2035
- fi
2036
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
2037
954
  if [ -z "$JWT" ]; then
2038
- jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
955
+ jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
2039
956
  exit 0
2040
957
  fi
2041
958
 
2042
959
  PAYLOAD=$(cat)
2043
960
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2044
- if [ -z "$CWD" ]; then
2045
- jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
2046
- exit 0
2047
- fi
2048
-
2049
- GIT_REPO=""
2050
- if command -v git >/dev/null 2>&1; then
2051
- _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
2052
- if [ -n "$_REMOTE" ]; then
2053
- GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
2054
- fi
2055
- fi
2056
-
2057
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-context" \\
2058
- --data-urlencode "cwd=$CWD" \\
2059
- --data-urlencode "repo=$GIT_REPO" \\
2060
- -H "Authorization: Bearer $JWT" \\
2061
- --max-time 2 2>/dev/null || echo "")
961
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
962
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2062
963
 
2063
- if [ -z "$RESP" ]; then
2064
- jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
2065
- exit 0
2066
- fi
964
+ RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/hook/config" \\
965
+ --data-urlencode "session_id=\${SESSION_ID:-}" \\
966
+ --data-urlencode "repo=\${GIT_REPO:-}" \\
967
+ -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
2067
968
 
2068
969
  PLAN_NUDGE="Before implementing any multi-step plan, call the synkro-guardrails analyze_plan tool with your implementation plan to check for relevant org coding rules."
2069
970
 
2070
- OPEN=$(echo "$RESP" | jq -r '.open_count // 0' 2>/dev/null)
2071
- if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
2072
- jq -n --arg sys_msg "$ROUTE_LINE"$'\\n'"[synkro] $PLAN_NUDGE" '{ systemMessage: $sys_msg }'
2073
- exit 0
971
+ OPEN=0
972
+ if [ -n "$RESP" ]; then
973
+ OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
2074
974
  fi
2075
975
 
2076
- if [ "$OPEN" = "1" ]; then
2077
- SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
976
+ if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
977
+ jq -n --arg m "$ROUTE_LINE"$'\\n'"[synkro] $PLAN_NUDGE" '{systemMessage: $m}'
2078
978
  else
2079
- SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
979
+ if [ "$OPEN" = "1" ]; then
980
+ SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
981
+ else
982
+ SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
983
+ fi
984
+ jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
2080
985
  fi
2081
-
2082
- jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
2083
986
  exit 0
2084
987
  `;
2085
988
  CC_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
2086
- # Synkro PostToolUse Bash hook \u2014 minimal correction-followup fire.
2087
- # No grading happens here; verdict already came from PreToolUse. This is just
2088
- # the "user approved + agent ran it" capture.
2089
- # No set -e: hook must ALWAYS produce JSON output. Silent death = CC timeout.
2090
-
2091
- CONFIG_FILE="$HOME/.synkro/config.env"
2092
- if [ -f "$CONFIG_FILE" ]; then
2093
- set -a
2094
- # shellcheck disable=SC1090
2095
- . "$CONFIG_FILE"
2096
- set +a
2097
- fi
2098
-
2099
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
2100
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
989
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
990
+ . "$SCRIPT_DIR/_synkro-common.sh"
2101
991
 
2102
- if [ ! -f "$CREDS_PATH" ]; then echo '{}'; exit 0; fi
2103
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
992
+ JWT=$(synkro_load_jwt)
2104
993
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2105
994
 
2106
995
  PAYLOAD=$(cat)
@@ -2112,47 +1001,22 @@ TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
2112
1001
  if [ -z "$SESSION_ID" ] || [ -z "$TOOL_USE_ID" ]; then echo '{}'; exit 0; fi
2113
1002
 
2114
1003
  BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
2115
- '{session_id: $sid, tool_use_id: $tid, decision: "allow"}')
1004
+ '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid}')
2116
1005
 
2117
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit/correction-followup" \\
2118
- -H "Content-Type: application/json" \\
2119
- -H "Authorization: Bearer $JWT" \\
2120
- -d "$BODY" \\
2121
- --max-time 2 \\
2122
- >/dev/null 2>&1 || true
1006
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1007
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1008
+ -d "$BODY" --max-time 2 >/dev/null 2>&1 || true
2123
1009
 
2124
1010
  echo '{}'
2125
1011
  exit 0
2126
1012
  `;
2127
1013
  CC_TRANSCRIPT_SYNC_SCRIPT = `#!/bin/bash
2128
- # Synkro Stop hook \u2014 incremental transcript sync.
2129
- # Reads new lines from the CC transcript since last sync, POSTs them to
2130
- # /api/v1/cli/sync-transcripts in the background. Completely invisible
2131
- # to the user \u2014 no systemMessage, no blocking.
2132
- # No set -e: hook must ALWAYS produce JSON output.
2133
-
2134
- CONFIG_FILE="$HOME/.synkro/config.env"
2135
- if [ -f "$CONFIG_FILE" ]; then
2136
- set -a
2137
- # shellcheck disable=SC1090
2138
- . "$CONFIG_FILE"
2139
- set +a
2140
- fi
2141
-
2142
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
2143
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
2144
-
2145
- if [ ! -f "$CREDS_PATH" ]; then echo '{}'; exit 0; fi
2146
- if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
1014
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1015
+ . "$SCRIPT_DIR/_synkro-common.sh"
2147
1016
 
2148
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
1017
+ JWT=$(synkro_load_jwt)
2149
1018
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2150
-
2151
- # Hard-skip in local_only privacy mode \u2014 conversation content must never leave the device.
2152
- ME_RESP=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/me" -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
2153
- SYNKRO_CAPTURE_DEPTH=$(echo "$ME_RESP" | jq -r '.capture_depth // empty' 2>/dev/null)
2154
- SYNKRO_CAPTURE_DEPTH="\${SYNKRO_CAPTURE_DEPTH:-local_only}"
2155
- if [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
1019
+ if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
2156
1020
 
2157
1021
  PAYLOAD=$(cat)
2158
1022
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
@@ -2160,200 +1024,64 @@ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/nul
2160
1024
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2161
1025
 
2162
1026
  if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
2163
- echo '{}'
2164
- exit 0
1027
+ echo '{}'; exit 0
2165
1028
  fi
2166
1029
 
2167
- # Detect git repo
2168
- GIT_REPO=""
2169
- if command -v git >/dev/null 2>&1; then
2170
- _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
2171
- if [ -n "$_REMOTE" ]; then
2172
- GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
2173
- fi
2174
- fi
1030
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2175
1031
  if [ -z "$GIT_REPO" ]; then echo '{}'; exit 0; fi
2176
1032
 
2177
- # Read offset (last synced line count)
1033
+ # Check capture depth \u2014 skip in local_only
1034
+ CONFIG_RESP=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config" -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1035
+ CAPTURE_DEPTH=$(echo "$CONFIG_RESP" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
1036
+ if [ "$CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
1037
+
2178
1038
  OFFSET_DIR="$HOME/.synkro/.transcript-offsets"
2179
1039
  mkdir -p "$OFFSET_DIR" 2>/dev/null || true
2180
1040
  OFFSET_FILE="$OFFSET_DIR/$SESSION_ID"
2181
1041
  OFFSET=0
2182
- if [ -f "$OFFSET_FILE" ]; then
2183
- OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
2184
- fi
1042
+ [ -f "$OFFSET_FILE" ] && OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
2185
1043
 
2186
1044
  TOTAL_LINES=$(wc -l < "$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ')
2187
- if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then
2188
- echo '{}'
2189
- exit 0
2190
- fi
1045
+ if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then echo '{}'; exit 0; fi
2191
1046
 
2192
1047
  DELTA=$((TOTAL_LINES - OFFSET))
2193
1048
  START_LINE=$((OFFSET + 1))
1049
+ [ "$DELTA" -gt 200 ] && START_LINE=$((TOTAL_LINES - 199))
2194
1050
 
2195
- # Cap at 200 lines per sync
2196
- if [ "$DELTA" -gt 200 ]; then
2197
- START_LINE=$((TOTAL_LINES - 199))
2198
- fi
2199
-
2200
- # Parse new transcript lines into structured messages
2201
1051
  MESSAGES=$(tail -n +"$START_LINE" "$TRANSCRIPT_PATH" 2>/dev/null | jq -c --argjson base_idx "$((START_LINE - 1))" '
2202
1052
  . as $line |
2203
1053
  if ($line.type == "user" or $line.type == "assistant") then
2204
1054
  {
2205
1055
  message_index: (input_line_number + $base_idx),
2206
1056
  type: $line.type,
2207
- content: (
2208
- if $line.type == "user" then
2209
- ($line.message.content
2210
- | if type == "string" then .[0:8000]
2211
- else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:8000])
2212
- end)
2213
- else
2214
- ([$line.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:8000])
2215
- end
2216
- ),
2217
- tool_calls: (
2218
- if $line.type == "assistant" then
2219
- [$line.message.content[]? | select(.type == "tool_use") | {name, input: (.input | tostring | .[0:500]), id}]
2220
- else null end
2221
- | if . == null or length == 0 then null else . end
2222
- ),
1057
+ 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),
1058
+ 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),
2223
1059
  model: ($line.message.model // null),
2224
- usage: (
2225
- if $line.type == "assistant" and $line.message.usage then
2226
- {
2227
- input_tokens: $line.message.usage.input_tokens,
2228
- output_tokens: $line.message.usage.output_tokens,
2229
- cache_creation_input_tokens: $line.message.usage.cache_creation_input_tokens,
2230
- cache_read_input_tokens: $line.message.usage.cache_read_input_tokens
2231
- }
2232
- else null end
2233
- )
1060
+ 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)
2234
1061
  }
2235
1062
  else empty end
2236
1063
  ' 2>/dev/null | jq -s '.' 2>/dev/null)
2237
1064
 
2238
1065
  if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
2239
1066
  printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
2240
- echo '{}'
2241
- exit 0
1067
+ echo '{}'; exit 0
2242
1068
  fi
2243
1069
 
2244
- BODY=$(jq -n \\
2245
- --arg repo "$GIT_REPO" \\
2246
- --arg sid "$SESSION_ID" \\
2247
- --argjson messages "$MESSAGES" \\
1070
+ BODY=$(jq -n --arg repo "$GIT_REPO" --arg sid "$SESSION_ID" --argjson messages "$MESSAGES" \\
2248
1071
  '{repo: $repo, sessions: [{cc_session_id: $sid, messages: $messages}]}')
2249
1072
 
2250
- # Fire-and-forget \u2014 background the curl so we don't block the user
2251
1073
  (
2252
1074
  curl -sS -X POST "\${GATEWAY_URL}/api/v1/cli/sync-transcripts" \\
2253
- -H "Content-Type: application/json" \\
2254
- -H "Authorization: Bearer $JWT" \\
2255
- -d "$BODY" \\
2256
- --max-time 10 \\
2257
- >/dev/null 2>&1
1075
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1076
+ -d "$BODY" --max-time 10 >/dev/null 2>&1
2258
1077
  ) &
2259
1078
  disown 2>/dev/null || true
2260
1079
 
2261
- # Update offset
2262
1080
  printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
2263
-
2264
- echo '{}'
2265
- exit 0
2266
- `;
2267
- SYNKRO_COMMON_SCRIPT = `#!/bin/bash
2268
- # Shared Synkro hook utilities \u2014 sourced by IDE-specific adapter scripts.
2269
- # Provides: auth, JWT refresh, config loading, API helpers, git detection.
2270
-
2271
- synkro_log() { echo "[synkro] $1" >&2; }
2272
-
2273
- synkro_channel_up() {
2274
- (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
2275
- }
2276
-
2277
- # Load config
2278
- _SYNKRO_CONFIG="$HOME/.synkro/config.env"
2279
- if [ -f "$_SYNKRO_CONFIG" ]; then
2280
- set -a
2281
- # shellcheck disable=SC1090
2282
- . "$_SYNKRO_CONFIG"
2283
- set +a
2284
- fi
2285
-
2286
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
2287
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
2288
-
2289
- synkro_load_jwt() {
2290
- if [ ! -f "$CREDS_PATH" ]; then
2291
- echo ""
2292
- return 1
2293
- fi
2294
- jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null
2295
- }
2296
-
2297
- synkro_refresh_jwt() {
2298
- local refresh_token
2299
- refresh_token=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
2300
- if [ -z "$refresh_token" ]; then return 1; fi
2301
- local refresh_body
2302
- refresh_body=$(jq -n --arg rt "$refresh_token" '{refresh_token:$rt}')
2303
- local refresh_resp
2304
- refresh_resp=$(curl -sS -X POST "\${GATEWAY_URL}/api/auth/refresh" \\
2305
- -H "Content-Type: application/json" \\
2306
- -d "$refresh_body" \\
2307
- --max-time 4 2>/dev/null)
2308
- local new_access
2309
- new_access=$(echo "$refresh_resp" | jq -r '.access_token // empty' 2>/dev/null)
2310
- if [ -z "$new_access" ]; then return 1; fi
2311
- local new_refresh
2312
- new_refresh=$(echo "$refresh_resp" | jq -r '.refresh_token // empty' 2>/dev/null)
2313
- if [ -z "$new_refresh" ]; then new_refresh="$refresh_token"; fi
2314
- local tmp="\${CREDS_PATH}.synkro.tmp"
2315
- jq --arg at "$new_access" --arg rt "$new_refresh" \\
2316
- '. + {access_token: $at, refresh_token: $rt}' \\
2317
- "$CREDS_PATH" > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
2318
- JWT="$new_access"
2319
- return 0
2320
- }
2321
-
2322
- synkro_ensure_fresh_jwt() {
2323
- [ -z "$JWT" ] && return 1
2324
- local payload exp now remaining
2325
- payload=$(printf '%s' "$JWT" | cut -d. -f2)
2326
- case $((\${#payload} % 4)) in
2327
- 2) payload="\${payload}==" ;;
2328
- 3) payload="\${payload}=" ;;
2329
- esac
2330
- exp=$(printf '%s' "$payload" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
2331
- now=$(date -u +%s)
2332
- remaining=$((exp - now))
2333
- if [ "$remaining" -lt 60 ]; then
2334
- synkro_refresh_jwt
2335
- fi
2336
- }
2337
-
2338
- synkro_detect_repo() {
2339
- local cwd="\${1:-.}"
2340
- if command -v git >/dev/null 2>&1; then
2341
- local remote
2342
- remote=$(git -C "$cwd" remote get-url origin 2>/dev/null || true)
2343
- if [ -n "$remote" ]; then
2344
- echo "$remote" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||'
2345
- return
2346
- fi
2347
- fi
2348
- echo ""
2349
- }
1081
+ echo '{}'; exit 0
2350
1082
  `;
2351
1083
  CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
2352
- # Synkro beforeShellExecution hook for Cursor.
2353
- # Reads Cursor's stdin payload, judges via Synkro gateway, returns Cursor-format verdict.
2354
-
2355
1084
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2356
- # shellcheck disable=SC1091
2357
1085
  . "$SCRIPT_DIR/_synkro-common.sh"
2358
1086
 
2359
1087
  JWT=$(synkro_load_jwt)
@@ -2373,95 +1101,38 @@ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2373
1101
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
2374
1102
  synkro_log "bashGuard checking: $CMD_SHORT"
2375
1103
 
2376
- TOOL_INPUT=$(jq -n --arg cmd "$COMMAND" '{command: $cmd}')
2377
-
2378
1104
  BODY=$(jq -n \\
2379
- --argjson tool_input "$TOOL_INPUT" \\
1105
+ --arg cmd "$COMMAND" \\
2380
1106
  --arg session_id "$SESSION_ID" \\
2381
1107
  --arg cwd "$CWD" \\
2382
1108
  --arg repo "$GIT_REPO" \\
2383
1109
  '{
2384
- kind: "bash_judge",
2385
- tool_input: $tool_input,
2386
- user_intent: null,
2387
- recent_user_messages: [],
2388
- recent_messages: [],
2389
- recent_actions: [],
1110
+ hook_event: "PreToolUse",
1111
+ tool_name: "Bash",
1112
+ tool_input: {command: $cmd},
1113
+ response_format: "cursor",
2390
1114
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
2391
1115
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
2392
- repo: (if ($repo | length) > 0 then $repo else null end),
2393
- ide: "cursor"
1116
+ repo: (if ($repo | length) > 0 then $repo else null end)
2394
1117
  }')
2395
1118
 
2396
- VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
2397
- -H "Content-Type: application/json" \\
2398
- -H "Authorization: Bearer $JWT" \\
2399
- -d "$BODY" \\
2400
- --max-time 6 2>/dev/null || echo "")
2401
-
2402
- if echo "$VERDICT" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
2403
- if synkro_refresh_jwt; then
2404
- VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
2405
- -H "Content-Type: application/json" \\
2406
- -H "Authorization: Bearer $JWT" \\
2407
- -d "$BODY" \\
2408
- --max-time 6 2>/dev/null || echo "")
2409
- fi
2410
- fi
1119
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
2411
1120
 
2412
- if [ -z "$VERDICT" ]; then
1121
+ if [ -z "$RESP" ]; then
2413
1122
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
2414
- echo '{}'
2415
- exit 0
1123
+ echo '{}'; exit 0
2416
1124
  fi
2417
1125
 
2418
- SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "audit"' 2>/dev/null)
2419
- REASONING=$(echo "$VERDICT" | jq -r '.reasoning // ""' 2>/dev/null)
2420
- ALTERNATIVE=$(echo "$VERDICT" | jq -r '.alternative // ""' 2>/dev/null)
2421
- CATEGORY=$(echo "$VERDICT" | jq -r '.category // ""' 2>/dev/null)
2422
- VERDICT_KIND=$(echo "$VERDICT" | jq -r '.verdict // "warn"' 2>/dev/null)
2423
-
2424
- case "$SEVERITY" in
2425
- block|audit) ;;
2426
- low|medium|high|critical)
2427
- if [ "$VERDICT_KIND" = "allow" ]; then SEVERITY="audit"; else SEVERITY="block"; fi
2428
- ;;
2429
- *)
2430
- if [ "$VERDICT_KIND" = "allow" ]; then SEVERITY="audit"; else SEVERITY="block"; fi
2431
- ;;
2432
- esac
2433
-
2434
- ALT_SUFFIX=""
2435
- if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
2436
- ALT_SUFFIX=" Suggested: \${ALTERNATIVE}"
1126
+ # Server returns cursor-format directly in hook_response
1127
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1128
+ echo "$RESP" | jq -c '.hook_response'
1129
+ else
1130
+ echo '{}'
2437
1131
  fi
2438
-
2439
- case "$SEVERITY" in
2440
- block)
2441
- synkro_log "bashGuard $CMD_SHORT \u2192 BLOCK: $REASONING"
2442
- jq -n \\
2443
- --arg user "Synkro safety judge blocked this command: \${REASONING}\${ALT_SUFFIX}" \\
2444
- --arg agent "Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}). Reasoning: \${REASONING}.\${ALT_SUFFIX}" \\
2445
- '{permission: "deny", user_message: $user, agent_message: $agent}'
2446
- ;;
2447
- audit)
2448
- synkro_log "bashGuard $CMD_SHORT \u2192 pass (\${CATEGORY})"
2449
- echo '{}'
2450
- ;;
2451
- *)
2452
- synkro_log "bashGuard $CMD_SHORT \u2192 BLOCK (unexpected severity)"
2453
- jq -n \\
2454
- --arg user "Synkro safety judge blocked this command (unexpected severity)." \\
2455
- '{permission: "deny", user_message: $user}'
2456
- ;;
2457
- esac
1132
+ exit 0
2458
1133
  `;
2459
1134
  CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
2460
- # Synkro preToolUse hook for Cursor \u2014 pre-check edits against org rules.
2461
- # Only acts on edit-like tool names; passes through everything else.
2462
-
2463
1135
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2464
- # shellcheck disable=SC1091
2465
1136
  . "$SCRIPT_DIR/_synkro-common.sh"
2466
1137
 
2467
1138
  JWT=$(synkro_load_jwt)
@@ -2472,16 +1143,12 @@ PAYLOAD=$(cat)
2472
1143
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2473
1144
 
2474
1145
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
2475
-
2476
1146
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2477
1147
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2478
1148
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2479
1149
 
2480
1150
  FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
2481
1151
  CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
2482
-
2483
- # Skip non-edit tools \u2014 if there's no file path in tool_input, this isn't a file edit
2484
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2485
1152
  if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2486
1153
 
2487
1154
  BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
@@ -2494,46 +1161,33 @@ BODY=$(jq -n \\
2494
1161
  --arg cwd "$CWD" \\
2495
1162
  --arg repo "$GIT_REPO" \\
2496
1163
  '{
1164
+ hook_event: "PreToolUse",
1165
+ tool_name: "Edit",
1166
+ tool_input: {file_path: $file_path, content: $content},
2497
1167
  file_path: $file_path,
2498
1168
  content: $content,
1169
+ response_format: "cursor",
2499
1170
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
2500
1171
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
2501
- repo: (if ($repo | length) > 0 then $repo else null end),
2502
- ide: "cursor"
1172
+ repo: (if ($repo | length) > 0 then $repo else null end)
2503
1173
  }')
2504
1174
 
2505
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit" \\
2506
- -H "Content-Type: application/json" \\
2507
- -H "Authorization: Bearer $JWT" \\
2508
- -d "$BODY" \\
2509
- --max-time 8 2>/dev/null || echo "")
1175
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
2510
1176
 
2511
1177
  if [ -z "$RESP" ]; then
2512
1178
  synkro_log "editGuard $BASENAME \u2192 error (timeout)"
2513
- echo '{}'
2514
- exit 0
1179
+ echo '{}'; exit 0
2515
1180
  fi
2516
1181
 
2517
- DECISION=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
2518
- case "$DECISION" in
2519
- deny|ask)
2520
- REASON=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecisionReason // "Blocked by Synkro"' 2>/dev/null)
2521
- synkro_log "editGuard $BASENAME \u2192 BLOCK: $REASON"
2522
- jq -n --arg user "$REASON" --arg agent "$REASON" \\
2523
- '{permission: "deny", user_message: $user, agent_message: $agent}'
2524
- ;;
2525
- *)
2526
- synkro_log "editGuard $BASENAME \u2192 pass"
2527
- echo '{}'
2528
- ;;
2529
- esac
1182
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1183
+ echo "$RESP" | jq -c '.hook_response'
1184
+ else
1185
+ echo '{}'
1186
+ fi
1187
+ exit 0
2530
1188
  `;
2531
1189
  CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
2532
- # Synkro afterFileEdit hook for Cursor \u2014 fire-and-forget telemetry + CVE scan.
2533
- # Cannot block (Cursor afterFileEdit is observational only).
2534
-
2535
1190
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2536
- # shellcheck disable=SC1091
2537
1191
  . "$SCRIPT_DIR/_synkro-common.sh"
2538
1192
 
2539
1193
  JWT=$(synkro_load_jwt)
@@ -2550,19 +1204,11 @@ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2550
1204
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2551
1205
  BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
2552
1206
 
2553
- # Read full file content for edit scan
1207
+ FULL_PATH="$FILE_PATH"
1208
+ [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
2554
1209
  FULL_CONTENT=""
2555
- FULL_PATH=""
2556
- if [ -n "$CWD" ]; then
2557
- FULL_PATH="$CWD/$FILE_PATH"
2558
- else
2559
- FULL_PATH="$FILE_PATH"
2560
- fi
2561
- if [ -f "$FULL_PATH" ]; then
2562
- FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
2563
- fi
1210
+ [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
2564
1211
 
2565
- # Extract deps from nearest package.json
2566
1212
  DEPS_JSON="{}"
2567
1213
  _PKG_DIR="\${CWD:-.}"
2568
1214
  while [ "$_PKG_DIR" != "/" ]; do
@@ -2575,30 +1221,18 @@ done
2575
1221
 
2576
1222
  synkro_log "editScan $BASENAME"
2577
1223
 
2578
- # Fire-and-forget: edit scan + CVE scan in background
2579
1224
  (
2580
1225
  BODY=$(jq -n \\
2581
- --arg file_path "$FILE_PATH" \\
2582
- --arg content "$FULL_CONTENT" \\
2583
- --arg session_id "$SESSION_ID" \\
2584
- --arg cwd "$CWD" \\
2585
- --arg repo "$GIT_REPO" \\
1226
+ --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
1227
+ --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
2586
1228
  --argjson deps "$DEPS_JSON" \\
2587
- '{
2588
- file_path: $file_path,
2589
- content: $content,
2590
- dependencies: $deps,
2591
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
2592
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
2593
- repo: (if ($repo | length) > 0 then $repo else null end),
2594
- ide: "cursor"
2595
- }')
2596
-
2597
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/edit-scan" \\
2598
- -H "Content-Type: application/json" \\
2599
- -H "Authorization: Bearer $JWT" \\
2600
- -d "$BODY" \\
2601
- --max-time 10 >/dev/null 2>&1 || true
1229
+ '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
1230
+ + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
1231
+ + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
1232
+ + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
1233
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1234
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1235
+ -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
2602
1236
  ) &
2603
1237
  disown 2>/dev/null || true
2604
1238
 
@@ -2606,24 +1240,15 @@ echo '{}'
2606
1240
  exit 0
2607
1241
  `;
2608
1242
  CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
2609
- # Synkro postToolUse hook for Cursor \u2014 fire-and-forget follow-up telemetry.
2610
- # Marks bash judgments as "allowed" after successful execution.
2611
-
2612
1243
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2613
- # shellcheck disable=SC1091
2614
1244
  . "$SCRIPT_DIR/_synkro-common.sh"
2615
1245
 
2616
1246
  JWT=$(synkro_load_jwt)
2617
1247
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2618
1248
 
2619
1249
  PAYLOAD=$(cat)
2620
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2621
-
2622
1250
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
2623
- case "$TOOL_NAME" in
2624
- Shell|Bash|terminal|run_terminal_cmd|execute_command) ;;
2625
- *) echo '{}'; exit 0 ;;
2626
- esac
1251
+ case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
2627
1252
 
2628
1253
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2629
1254
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
@@ -2631,12 +1256,10 @@ TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
2631
1256
  if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
2632
1257
  (
2633
1258
  BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
2634
- '{session_id: $sid, tool_use_id: $tid, decision: "allow"}')
2635
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/events/bash-followup" \\
2636
- -H "Content-Type: application/json" \\
2637
- -H "Authorization: Bearer $JWT" \\
2638
- -d "$BODY" \\
2639
- --max-time 3 >/dev/null 2>&1 || true
1259
+ '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid}')
1260
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1261
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1262
+ -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
2640
1263
  ) &
2641
1264
  disown 2>/dev/null || true
2642
1265
  fi
@@ -4833,7 +3456,7 @@ function writeConfigEnv(opts) {
4833
3456
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
4834
3457
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
4835
3458
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
4836
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.14")}`
3459
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.15")}`
4837
3460
  ];
4838
3461
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
4839
3462
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);