@synkro-sh/cli 1.4.14 → 1.4.16

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,256 @@ 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_channel_up() {
499
+ (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
500
+ }
512
501
 
513
- CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
514
- synkro_log "bashGuard checking: $CMD_SHORT"
502
+ # Fetch hook config (cached 5min). Sets SYNKRO_CAPTURE_DEPTH, SYNKRO_TIER, SYNKRO_RULES, SYNKRO_GRADER_PRIMER_BASH, SYNKRO_GRADER_PRIMER_EDIT, SYNKRO_CLASSIFICATION_PROMPT.
503
+ synkro_load_config() {
504
+ local cache="$HOME/.synkro/.hook-config-cache"
505
+ if [ -f "$cache" ] && find "$cache" -mmin -5 2>/dev/null | grep -q .; then
506
+ eval "$(cat "$cache" 2>/dev/null)"
507
+ return
508
+ fi
509
+ local resp
510
+ resp=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config\${1:+?$1}" -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
511
+ if [ -z "$resp" ]; then return; fi
512
+ SYNKRO_CAPTURE_DEPTH=$(echo "$resp" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
513
+ SYNKRO_TIER=$(echo "$resp" | jq -r '.tier // "standard"' 2>/dev/null)
514
+ SYNKRO_RULES=$(echo "$resp" | jq -c '[.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
515
+ # Cache the values
516
+ printf 'SYNKRO_CAPTURE_DEPTH="%s"\\nSYNKRO_TIER="%s"\\n' "$SYNKRO_CAPTURE_DEPTH" "$SYNKRO_TIER" > "$cache" 2>/dev/null || true
517
+ }
515
518
 
516
- # All commands are graded; no client-side regex gate.
519
+ # Decide routing: "local" (grade on device) or "cloud" (POST to server)
520
+ synkro_route() {
521
+ [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
522
+ synkro_channel_up && echo "local" && return
523
+ echo "cloud"
524
+ }
525
+
526
+ # Grade locally via synkro CLI or claude --print. Reads prompt from stdin.
527
+ synkro_local_grade() {
528
+ local surface="$1"
529
+ if synkro_channel_up && [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
530
+ node "$SYNKRO_CLI_BIN" grade "$surface" 2>/dev/null
531
+ elif synkro_channel_up && command -v synkro >/dev/null 2>&1; then
532
+ synkro grade "$surface" 2>/dev/null
533
+ elif command -v claude >/dev/null 2>&1; then
534
+ claude --print --model claude-sonnet-4-6 --no-session-persistence 2>/dev/null
535
+ fi
536
+ }
537
+
538
+ # Parse <synkro-verdict>...</synkro-verdict> XML from local grader output.
539
+ # Sets LOCAL_OK, LOCAL_REASON, LOCAL_RULE_ID, LOCAL_SEV, LOCAL_CAT.
540
+ synkro_parse_local_verdict() {
541
+ local resp="$1"
542
+ LOCAL_OK="true"; LOCAL_REASON=""; LOCAL_RULE_ID=""; LOCAL_SEV="low"; LOCAL_CAT="general"
543
+ local inner
544
+ inner=$(printf '%s' "$resp" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
545
+ [ -z "$inner" ] && return
546
+ local ok_tag
547
+ ok_tag=$(printf '%s' "$inner" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
548
+ [ -n "$ok_tag" ] && LOCAL_OK="$ok_tag"
549
+ if [ "$LOCAL_OK" = "false" ]; then
550
+ local fv
551
+ fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
552
+ LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
553
+ LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
554
+ LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
555
+ LOCAL_CAT=$(printf '%s' "$fv" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
556
+ LOCAL_SEV="\${LOCAL_SEV:-high}"; LOCAL_CAT="\${LOCAL_CAT:-policy_violation}"
557
+ fi
558
+ }
559
+
560
+ # Fire anonymized telemetry for local verdicts. All args positional.
561
+ synkro_capture_local() {
562
+ local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
563
+ (
564
+ BODY=$(jq -n \\
565
+ --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
566
+ --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
567
+ --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" \\
568
+ '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,model:"claude-sonnet-4-6",tool_name:$tn}
569
+ + (if $r != "" then {repo:$r} else {} end)
570
+ + (if $sid != "" then {session_id:$sid} else {} end)')
571
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
572
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
573
+ -d "$BODY" --max-time 2 >/dev/null 2>&1
574
+ ) &
575
+ }
576
+
577
+ synkro_post_with_retry() {
578
+ local url="$1" body="$2" timeout="\${3:-8}"
579
+ local resp
580
+ resp=$(curl -sS -X POST "$url" \\
581
+ -H "Content-Type: application/json" \\
582
+ -H "Authorization: Bearer $JWT" \\
583
+ -d "$body" --max-time "$timeout" 2>/dev/null || echo "")
584
+ if echo "$resp" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
585
+ if synkro_refresh_jwt; then
586
+ resp=$(curl -sS -X POST "$url" \\
587
+ -H "Content-Type: application/json" \\
588
+ -H "Authorization: Bearer $JWT" \\
589
+ -d "$body" --max-time "$timeout" 2>/dev/null || echo "")
590
+ fi
591
+ fi
592
+ echo "$resp"
593
+ }
594
+ `;
595
+ CC_BASH_JUDGE_SCRIPT = `#!/bin/bash
596
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
597
+ . "$SCRIPT_DIR/_synkro-common.sh"
598
+
599
+ JWT=$(synkro_load_jwt)
600
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
601
+ synkro_ensure_fresh_jwt
602
+
603
+ PAYLOAD=$(cat)
604
+ if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
605
+
606
+ TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
607
+ case "$TOOL_NAME" in Bash|Read|Grep|Glob) ;; *) echo '{}'; exit 0 ;; esac
517
608
 
518
- # Extract context from the transcript file
519
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
520
609
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
521
610
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
522
611
  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.
612
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
534
613
  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 ;;
614
+
615
+ # Translate tool calls to command string for logging
616
+ case "$TOOL_NAME" in
617
+ Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
618
+ Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
619
+ 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)" ;;
620
+ Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
538
621
  esac
539
- if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
622
+ if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
623
+
624
+ CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
625
+ synkro_log "bashGuard checking: $CMD_SHORT"
540
626
 
627
+ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
541
628
  USER_INTENT=""
542
629
  RECENT_USER_MESSAGES="[]"
543
- RECENT_MESSAGES="[]"
544
- RECENT_ACTIONS="[]"
545
630
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
546
- 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)
553
- | select(. != null and . != "")
554
- ] | .[-5:]' 2>/dev/null || echo "[]")
631
+ RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user") | (.message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end) | select(. != null and . != "")] | .[-5:]' 2>/dev/null || echo "[]")
555
632
  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
- 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)
575
- ] | .[-10:]' 2>/dev/null || echo "[]")
576
- # Recent agent actions (last 5 tool_use blocks paired with results)
577
- 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
- }
595
- ] | .[-5:]' 2>/dev/null || echo "[]")
596
633
  fi
597
634
 
598
- CC_MODEL=""
599
- CC_USAGE="{}"
600
- 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 "{}")
635
+ # Headless detection
636
+ IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
637
+ case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
638
+
639
+ synkro_load_config
640
+ ROUTE=$(synkro_route)
641
+
642
+ if [ "$ROUTE" = "local" ]; then
643
+ # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
644
+ GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
645
+ trap "rm -f \\"$GRADER_FILE\\"" EXIT
646
+ printf 'Command: %s\\nUser intent: %s\\nOrg rules: %s\\n' "$COMMAND" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
647
+
648
+ CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" || echo "")
649
+ synkro_parse_local_verdict "$CC_RESP"
650
+
651
+ if [ "$LOCAL_OK" = "false" ]; then
652
+ if [ "$IS_HEADLESS" = "1" ]; then DEC="deny"; else DEC="ask"; fi
653
+ REASON="[synkro:local] \${LOCAL_RULE_ID:+$LOCAL_RULE_ID: }\${LOCAL_REASON:-policy violation}"
654
+ jq -n --arg dec "$DEC" --arg reason "$REASON" --arg ctx "$REASON" \\
655
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$dec,permissionDecisionReason:$reason,additionalContext:$ctx}}'
656
+ synkro_capture_local "bash" "warn" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
657
+ else
658
+ jq -n --arg m "[synkro:local] bashGuard \u2192 pass" '{systemMessage: $m}'
659
+ synkro_capture_local "bash" "allow" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
612
660
  fi
661
+ exit 0
613
662
  fi
614
663
 
615
- # Extract session summary from CC compaction (free broad context)
664
+ # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
665
+ CC_MODEL=""
666
+ CC_USAGE="{}"
667
+ RECENT_MESSAGES="[]"
668
+ RECENT_ACTIONS="[]"
616
669
  SESSION_SUMMARY=""
617
670
  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 "")
671
+ _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
672
+ if [ -n "$_LAST" ]; then
673
+ CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
674
+ 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 "{}")
625
675
  fi
676
+ RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user" or .type == "assistant") | {type, text: (.message.content | if type == "string" then .[0:500] else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}] | .[-10:]' 2>/dev/null || echo "[]")
677
+ RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}] | .[-5:]' 2>/dev/null || echo "[]")
678
+ SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
626
679
  fi
627
680
 
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
681
  BODY=$(jq -n \\
632
- --argjson tool_input "$TOOL_INPUT" \\
682
+ --arg hook_event "PreToolUse" \\
683
+ --arg tool_name "$TOOL_NAME" \\
684
+ --argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
633
685
  --arg user_intent "$USER_INTENT" \\
634
686
  --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
635
687
  --argjson recent_messages "$RECENT_MESSAGES" \\
@@ -638,11 +690,13 @@ BODY=$(jq -n \\
638
690
  --arg tool_use_id "$TOOL_USE_ID" \\
639
691
  --arg cwd "$CWD" \\
640
692
  --arg repo "$GIT_REPO" \\
693
+ --arg permission_mode "$PERMISSION_MODE" \\
641
694
  --arg cc_model "$CC_MODEL" \\
642
695
  --argjson cc_usage "$CC_USAGE" \\
643
696
  --arg session_summary "$SESSION_SUMMARY" \\
644
697
  '{
645
- kind: "bash_judge",
698
+ hook_event: $hook_event,
699
+ tool_name: $tool_name,
646
700
  tool_input: $tool_input,
647
701
  user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
648
702
  recent_user_messages: $recent_user_messages,
@@ -652,437 +706,69 @@ BODY=$(jq -n \\
652
706
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
653
707
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
654
708
  repo: (if ($repo | length) > 0 then $repo else null end),
709
+ permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
655
710
  cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
656
711
  cc_usage: $cc_usage,
657
712
  session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
658
713
  }')
659
714
 
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
- }
715
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
693
716
 
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
- }
709
-
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
717
+ if [ -z "$RESP" ]; then
801
718
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
802
- jq -n --arg m "[synkro:\${ROUTE_TAG:-cloud}] bashGuard \u2192 error (timeout)" '{systemMessage: $m}'
719
+ echo '{}'
803
720
  exit 0
804
721
  fi
805
722
 
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
- ) &
723
+ if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
724
+ synkro_log "bashGuard $CMD_SHORT \u2192 pass (no hook_response)"
725
+ echo '{}'
726
+ exit 0
966
727
  fi
967
728
 
729
+ echo "$RESP" | jq -c '.hook_response'
968
730
  exit 0
969
731
  `;
970
732
  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}"
733
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
734
+ . "$SCRIPT_DIR/_synkro-common.sh"
991
735
 
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
736
+ JWT=$(synkro_load_jwt)
737
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
738
+ synkro_ensure_fresh_jwt
1001
739
 
1002
740
  PAYLOAD=$(cat)
1003
- if [ -z "$PAYLOAD" ]; then
1004
- echo '{}'
1005
- exit 0
1006
- fi
741
+ if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1007
742
 
1008
743
  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
744
+ case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1013
745
 
1014
746
  TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1015
747
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1016
748
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1017
749
  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.
750
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1029
751
  PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
1030
- HEADLESS_FLAG="\${SYNKRO_HEADLESS:-0}"
1031
752
 
1032
753
  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
754
+ if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1037
755
 
1038
756
  FILE_SHORT=$(basename "$FILE_PATH")
1039
757
  synkro_log "editGuard checking: $FILE_SHORT"
1040
758
 
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
759
+ IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
760
+ case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
1058
761
 
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.
762
+ # Read file before edit for reconstruction
1063
763
  FILE_BEFORE=""
1064
764
  if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
1065
765
  FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1066
766
  fi
1067
767
 
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.
768
+ # Reconstruct proposed content
1071
769
  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) ;;
770
+ Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
1075
771
  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
772
  if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
1087
773
  PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
1088
774
  import os, json, sys
@@ -1090,47 +776,69 @@ fb = os.environ.get("FILE_BEFORE_LITERAL", "")
1090
776
  ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
1091
777
  result = fb
1092
778
  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)
779
+ if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
1095
780
  elif "edits" in ti and isinstance(ti["edits"], list):
1096
781
  for e in ti["edits"]:
1097
782
  old = e.get("old_string", "") if isinstance(e, dict) else ""
1098
783
  new = e.get("new_string", "") if isinstance(e, dict) else ""
1099
- if old:
1100
- result = result.replace(old, new, 1)
784
+ if old: result = result.replace(old, new, 1)
1101
785
  sys.stdout.write(result)
1102
786
  ' 2>/dev/null)
1103
787
  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
788
  if [ -z "$PROPOSED" ]; then
1107
789
  if [ "$TOOL_NAME" = "MultiEdit" ]; then
1108
790
  PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
1109
791
  else
1110
792
  PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
1111
793
  fi
1112
- fi
1113
- ;;
1114
- NotebookEdit)
1115
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
794
+ fi ;;
795
+ NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
1116
796
  esac
1117
-
1118
- if [ -z "$PROPOSED" ]; then
1119
- echo '{}'
1120
- exit 0
1121
- fi
797
+ if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
1122
798
 
1123
799
  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"
800
+ [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
801
+
802
+ # Extract user intent from transcript
803
+ USER_INTENT=""
804
+ RECENT_ACTIONS="[]"
805
+ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
806
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
807
+ 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 "")
808
+ 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 "[]")
1126
809
  fi
1127
810
 
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.
811
+ synkro_load_config
812
+ ROUTE=$(synkro_route)
813
+
814
+ if [ "$ROUTE" = "local" ]; then
815
+ # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
816
+ GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
817
+ trap "rm -f \\"$GRADER_FILE\\"" EXIT
818
+ printf 'File: %s\\nProposed content (first 4000 chars):\\n%s\\nUser intent: %s\\nOrg rules: %s\\n' "$FILE_PATH" "$(printf '%s' "$PROPOSED" | head -c 4000)" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
819
+
820
+ CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" || echo "")
821
+ synkro_parse_local_verdict "$CC_RESP"
1130
822
 
823
+ if [ "$LOCAL_OK" = "false" ]; then
824
+ if [ "$IS_HEADLESS" = "1" ]; then DEC="deny"; else DEC="ask"; fi
825
+ REASON="[synkro:local] \${LOCAL_RULE_ID:+$LOCAL_RULE_ID: }\${LOCAL_REASON:-policy violation}"
826
+ jq -n --arg dec "$DEC" --arg reason "$REASON" --arg ctx "$REASON" \\
827
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$dec,permissionDecisionReason:$reason,additionalContext:$ctx}}'
828
+ synkro_capture_local "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
829
+ else
830
+ jq -n --arg m "[synkro:local] editGuard $FILE_SHORT \u2192 pass" '{systemMessage: $m}'
831
+ synkro_capture_local "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
832
+ fi
833
+ exit 0
834
+ fi
835
+
836
+ # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1131
837
  BODY=$(jq -n \\
1132
- --arg file_path "$FILE_PATH" \\
838
+ --arg hook_event "PreToolUse" \\
1133
839
  --arg tool_name "$TOOL_NAME" \\
840
+ --argjson tool_input "$TOOL_INPUT" \\
841
+ --arg file_path "$FILE_PATH" \\
1134
842
  --arg content "$PROPOSED" \\
1135
843
  --arg file_before "$FILE_BEFORE" \\
1136
844
  --argjson diff "$DIFF_FIELD" \\
@@ -1139,12 +847,14 @@ BODY=$(jq -n \\
1139
847
  --arg session_id "$SESSION_ID" \\
1140
848
  --arg tool_use_id "$TOOL_USE_ID" \\
1141
849
  --arg cwd "$CWD" \\
1142
- --arg permission_mode "$PERMISSION_MODE" \\
1143
- --arg headless_flag "$HEADLESS_FLAG" \\
1144
850
  --arg repo "$GIT_REPO" \\
851
+ --arg permission_mode "$PERMISSION_MODE" \\
852
+ --arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
1145
853
  '{
1146
- file_path: $file_path,
854
+ hook_event: $hook_event,
1147
855
  tool_name: $tool_name,
856
+ tool_input: $tool_input,
857
+ file_path: $file_path,
1148
858
  content: $content,
1149
859
  file_before: (if ($file_before | length) > 0 then $file_before else null end),
1150
860
  diff: $diff,
@@ -1153,844 +863,213 @@ BODY=$(jq -n \\
1153
863
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1154
864
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1155
865
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
866
+ repo: (if ($repo | length) > 0 then $repo else null end),
1156
867
  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)
868
+ headless: ($headless_flag == "1")
1159
869
  }')
1160
870
 
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
871
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
1361
872
 
1362
873
  if [ -z "$RESP" ]; then
1363
874
  synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
1364
- jq -n --arg m "$SYNKRO_PREFIX editGuard $FILE_SHORT \u2192 error (timeout)" '{systemMessage: $m}'
875
+ echo '{}'
1365
876
  exit 0
1366
877
  fi
1367
878
 
1368
879
  if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1369
880
  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}'
881
+ echo '{}'
1371
882
  exit 0
1372
883
  fi
1373
884
 
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"
885
+ DECISION=$(echo "$RESP" | jq -r '.hook_response.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
886
+ if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
887
+ synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
888
+ echo "$RESP" | jq -c '.hook_response'
1379
889
  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}')
890
+ REASON=$(echo "$RESP" | jq -r '.hook_response.reason // empty' 2>/dev/null)
891
+ if [ -n "$REASON" ]; then
892
+ synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
1384
893
  else
1385
894
  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
895
  fi
1388
- echo "$RESP_WITH_MSG"
896
+ echo "$RESP" | jq -c '.hook_response // {}'
1389
897
  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
- 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
- ) &
1453
- fi
1454
-
1455
898
  exit 0
1456
899
  `;
1457
900
  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}"
901
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
902
+ . "$SCRIPT_DIR/_synkro-common.sh"
1479
903
 
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
904
+ JWT=$(synkro_load_jwt)
905
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
906
+ synkro_ensure_fresh_jwt
1489
907
 
1490
908
  PAYLOAD=$(cat)
1491
- if [ -z "$PAYLOAD" ]; then
1492
- echo '{}'
1493
- exit 0
1494
- fi
909
+ if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1495
910
 
1496
911
  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
912
+ case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1501
913
 
1502
914
  TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1503
915
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1504
916
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1505
917
  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
918
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
919
+
920
+ # Correction followup (backgrounded)
921
+ if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
922
+ (
923
+ 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"}')
924
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
925
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
926
+ -d "$BODY" --max-time 2 >/dev/null 2>&1
927
+ ) &
1513
928
  fi
1514
929
 
930
+ # Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
1515
931
  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
932
+ if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1520
933
 
1521
934
  BASENAME=$(basename "$FILE_PATH")
1522
- synkro_log "editScan checking: $BASENAME"
935
+ synkro_log "editScan: $BASENAME"
1523
936
 
1524
- # Read post-edit file content (cap 64KB).
1525
937
  FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1526
- if [ -z "$FILE_CONTENT" ]; then
1527
- echo '{}'
1528
- exit 0
1529
- fi
938
+ if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
1530
939
 
1531
940
  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
941
+ [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
1535
942
 
1536
- # Resolve dependency versions + CVE config from nearest package.json / .synkro.json
1537
943
  DEPS_JSON="{}"
1538
- CVE_ALLOWLIST="[]"
1539
- CVE_MIN_SEVERITY="null"
1540
944
  _PKG_DIR=$(dirname "$FILE_PATH")
1541
945
  while [ "$_PKG_DIR" != "/" ]; do
1542
946
  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
947
+ DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
1550
948
  break
1551
949
  fi
1552
- _PKG_DIR=$(dirname "$_PKG_DIR")
1553
- done
950
+ _PKG_DIR=$(dirname "$_PKG_DIR")
951
+ done
952
+
953
+ synkro_load_config
954
+ ROUTE=$(synkro_route)
955
+
956
+ if [ "$ROUTE" = "local" ]; then
957
+ # \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
958
+ GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
959
+ trap "rm -f \\"$GRADER_FILE\\"" EXIT
960
+ printf 'File: %s\\nContent (first 4000 chars):\\n%s\\nOrg rules: %s\\n' "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
961
+
962
+ CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" || echo "")
963
+ synkro_parse_local_verdict "$CC_RESP"
964
+
965
+ if [ "$LOCAL_OK" = "false" ]; then
966
+ REASON="[synkro:local] editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
967
+ jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
968
+ synkro_capture_local "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
969
+ else
970
+ jq -n --arg m "[synkro:local] editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
971
+ synkro_capture_local "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
972
+ fi
973
+ exit 0
974
+ fi
1554
975
 
976
+ # \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
1555
977
  BODY=$(jq -n \\
978
+ --arg hook_event "PostToolUse" \\
979
+ --arg tool_name "$TOOL_NAME" \\
980
+ --argjson tool_input "$TOOL_INPUT" \\
1556
981
  --arg file_path "$FILE_PATH" \\
1557
982
  --arg content "$FILE_CONTENT" \\
1558
983
  --argjson diff "$DIFF_FIELD" \\
1559
- --argjson deps "$DEPS_JSON" \\
1560
- --argjson cve_allowlist "$CVE_ALLOWLIST" \\
1561
- --argjson cve_min_severity "$CVE_MIN_SEVERITY" \\
984
+ --argjson dependencies "$DEPS_JSON" \\
1562
985
  --arg session_id "$SESSION_ID" \\
1563
986
  --arg tool_use_id "$TOOL_USE_ID" \\
1564
987
  --arg cwd "$CWD" \\
1565
988
  --arg repo "$GIT_REPO" \\
1566
989
  '{
990
+ hook_event: $hook_event,
991
+ tool_name: $tool_name,
992
+ tool_input: $tool_input,
1567
993
  file_path: $file_path,
1568
994
  content: $content,
1569
995
  diff: $diff,
1570
- dependencies: $deps,
1571
- cve_allowlist: $cve_allowlist,
1572
- cve_min_severity: $cve_min_severity,
996
+ dependencies: $dependencies,
1573
997
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1574
998
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1575
999
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
1576
1000
  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 "")
1001
+ }')
1781
1002
 
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
1003
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
1792
1004
 
1793
1005
  if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1794
1006
  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
- }'
1007
+ echo '{}'
1876
1008
  exit 0
1877
1009
  fi
1878
1010
 
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}'
1011
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1012
+ echo "$RESP" | jq -c '.hook_response'
1882
1013
  else
1883
- synkro_log "editScan $BASENAME \u2192 pass"
1884
- jq -n --arg m "$SYNKRO_PREFIX editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
1014
+ echo '{}'
1885
1015
  fi
1886
1016
  exit 0
1887
1017
  `;
1888
1018
  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}"
1019
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1020
+ . "$SCRIPT_DIR/_synkro-common.sh"
1904
1021
 
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
1022
+ JWT=$(synkro_load_jwt)
1023
+ if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1914
1024
 
1915
1025
  PAYLOAD=$(cat)
1916
1026
  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
1027
+ if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
1921
1028
 
1922
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1923
1029
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1030
+ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1031
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1924
1032
 
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
1033
+ # Fire-and-forget usage telemetry
1934
1034
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1935
1035
  (
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 "{}")
1036
+ _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1037
+ if [ -n "$_LAST" ]; then
1038
+ CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
1039
+ 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
1040
  HAS_TOKENS=$(echo "$CC_USAGE" | jq '(.input_tokens // 0) + (.output_tokens // 0)' 2>/dev/null)
1946
1041
  if [ -n "$HAS_TOKENS" ] && [ "$HAS_TOKENS" != "0" ]; then
1947
- USAGE_BODY=$(jq -n \\
1042
+ BODY=$(jq -n \\
1948
1043
  --arg event_id "usage_$(date +%s)_$$" \\
1949
- --arg hook_type "stop" \\
1950
- --arg verdict "allow" \\
1951
- --arg severity "none" \\
1044
+ --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1952
1045
  --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
1953
1046
  --arg cc_model "\${CC_MODEL:-}" \\
1954
- --arg repo "\${GIT_REPO:-}" \\
1955
- --arg session_id "$SESSION_ID" \\
1047
+ --arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
1956
1048
  --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
1049
+ '{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
1050
+ + (if $repo != "" then {repo:$repo} else {} end)
1051
+ + (if $session_id != "" then {session_id:$session_id} else {} end)
1052
+ + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1053
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1054
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1055
+ -d "$BODY" --max-time 2 >/dev/null 2>&1
1968
1056
  fi
1969
1057
  fi
1970
1058
  ) &
1971
1059
  fi
1972
1060
 
1973
- # Tight timeout \u2014 the user already finished their session, don't make them wait.
1974
1061
  RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-summary" \\
1975
1062
  --data-urlencode "session_id=$SESSION_ID" \\
1976
- -H "Authorization: Bearer $JWT" \\
1977
- --max-time 2 2>/dev/null || echo "")
1063
+ -H "Authorization: Bearer $JWT" --max-time 2 2>/dev/null || echo "")
1978
1064
 
1979
- if [ -z "$RESP" ]; then
1980
- echo '{}'
1981
- exit 0
1982
- fi
1065
+ if [ -z "$RESP" ]; then echo '{}'; exit 0; fi
1983
1066
 
1984
1067
  EDITS=$(echo "$RESP" | jq -r '.edits_scanned // 0' 2>/dev/null)
1985
1068
  FINDINGS=$(echo "$RESP" | jq -r '.findings // 0' 2>/dev/null)
1986
1069
  AUTO_FIXED=$(echo "$RESP" | jq -r '.auto_fixed // 0' 2>/dev/null)
1987
1070
  OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
1988
1071
 
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
1072
+ if [ "$EDITS" = "0" ] || [ -z "$EDITS" ]; then echo '{}'; exit 0; fi
1994
1073
 
1995
1074
  if [ "$FINDINGS" = "0" ] || [ -z "$FINDINGS" ]; then
1996
1075
  SYS_MSG="[synkro] stop \u2192 0 issues across \${EDITS} edit(s), session complete"
@@ -1998,29 +1077,16 @@ else
1998
1077
  SYS_MSG="[synkro] stop \u2192 \${FINDINGS} finding(s): \${AUTO_FIXED} auto-fixed, \${OPEN} open"
1999
1078
  fi
2000
1079
 
2001
- jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
1080
+ jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
2002
1081
  exit 0
2003
1082
  `;
2004
1083
  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
1084
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1085
+ . "$SCRIPT_DIR/_synkro-common.sh"
2017
1086
 
2018
- GATEWAY_URL="\${SYNKRO_GATEWAY_URL:-https://api.synkro.sh}"
2019
- CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
1087
+ JWT=$(synkro_load_jwt)
2020
1088
 
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.
1089
+ # Route preamble
2024
1090
  SYNKRO_PORT="\${SYNKRO_CHANNEL_PORT:-8929}"
2025
1091
  if (exec 3<>/dev/tcp/127.0.0.1/"$SYNKRO_PORT") 2>/dev/null; then
2026
1092
  exec 3<&- 3>&- 2>/dev/null || true
@@ -2029,78 +1095,45 @@ else
2029
1095
  ROUTE_LINE="[synkro] inference: cloud (local-cc channel not reachable)"
2030
1096
  fi
2031
1097
 
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
1098
  if [ -z "$JWT" ]; then
2038
- jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
1099
+ jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
2039
1100
  exit 0
2040
1101
  fi
2041
1102
 
2042
1103
  PAYLOAD=$(cat)
2043
1104
  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 "")
1105
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1106
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2062
1107
 
2063
- if [ -z "$RESP" ]; then
2064
- jq -n --arg m "$ROUTE_LINE" '{ systemMessage: $m }'
2065
- exit 0
2066
- fi
1108
+ RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/hook/config" \\
1109
+ --data-urlencode "session_id=\${SESSION_ID:-}" \\
1110
+ --data-urlencode "repo=\${GIT_REPO:-}" \\
1111
+ -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
2067
1112
 
2068
1113
  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
1114
 
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
1115
+ OPEN=0
1116
+ if [ -n "$RESP" ]; then
1117
+ OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
2074
1118
  fi
2075
1119
 
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"
1120
+ if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
1121
+ jq -n --arg m "$ROUTE_LINE"$'\\n'"[synkro] $PLAN_NUDGE" '{systemMessage: $m}'
2078
1122
  else
2079
- SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
1123
+ if [ "$OPEN" = "1" ]; then
1124
+ SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
1125
+ else
1126
+ SYS_MSG="$ROUTE_LINE"$'\\n'"[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
1127
+ fi
1128
+ jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
2080
1129
  fi
2081
-
2082
- jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
2083
1130
  exit 0
2084
1131
  `;
2085
1132
  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}"
1133
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1134
+ . "$SCRIPT_DIR/_synkro-common.sh"
2101
1135
 
2102
- if [ ! -f "$CREDS_PATH" ]; then echo '{}'; exit 0; fi
2103
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
1136
+ JWT=$(synkro_load_jwt)
2104
1137
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2105
1138
 
2106
1139
  PAYLOAD=$(cat)
@@ -2112,47 +1145,22 @@ TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
2112
1145
  if [ -z "$SESSION_ID" ] || [ -z "$TOOL_USE_ID" ]; then echo '{}'; exit 0; fi
2113
1146
 
2114
1147
  BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
2115
- '{session_id: $sid, tool_use_id: $tid, decision: "allow"}')
1148
+ '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid}')
2116
1149
 
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
1150
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1151
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1152
+ -d "$BODY" --max-time 2 >/dev/null 2>&1 || true
2123
1153
 
2124
1154
  echo '{}'
2125
1155
  exit 0
2126
1156
  `;
2127
1157
  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
1158
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1159
+ . "$SCRIPT_DIR/_synkro-common.sh"
2147
1160
 
2148
- JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
1161
+ JWT=$(synkro_load_jwt)
2149
1162
  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
1163
+ if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
2156
1164
 
2157
1165
  PAYLOAD=$(cat)
2158
1166
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
@@ -2160,200 +1168,64 @@ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/nul
2160
1168
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2161
1169
 
2162
1170
  if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
2163
- echo '{}'
2164
- exit 0
1171
+ echo '{}'; exit 0
2165
1172
  fi
2166
1173
 
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
1174
+ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2175
1175
  if [ -z "$GIT_REPO" ]; then echo '{}'; exit 0; fi
2176
1176
 
2177
- # Read offset (last synced line count)
1177
+ # Check capture depth \u2014 skip in local_only
1178
+ CONFIG_RESP=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config" -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1179
+ CAPTURE_DEPTH=$(echo "$CONFIG_RESP" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
1180
+ if [ "$CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
1181
+
2178
1182
  OFFSET_DIR="$HOME/.synkro/.transcript-offsets"
2179
1183
  mkdir -p "$OFFSET_DIR" 2>/dev/null || true
2180
1184
  OFFSET_FILE="$OFFSET_DIR/$SESSION_ID"
2181
1185
  OFFSET=0
2182
- if [ -f "$OFFSET_FILE" ]; then
2183
- OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
2184
- fi
1186
+ [ -f "$OFFSET_FILE" ] && OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
2185
1187
 
2186
1188
  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
1189
+ if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then echo '{}'; exit 0; fi
2191
1190
 
2192
1191
  DELTA=$((TOTAL_LINES - OFFSET))
2193
1192
  START_LINE=$((OFFSET + 1))
1193
+ [ "$DELTA" -gt 200 ] && START_LINE=$((TOTAL_LINES - 199))
2194
1194
 
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
1195
  MESSAGES=$(tail -n +"$START_LINE" "$TRANSCRIPT_PATH" 2>/dev/null | jq -c --argjson base_idx "$((START_LINE - 1))" '
2202
1196
  . as $line |
2203
1197
  if ($line.type == "user" or $line.type == "assistant") then
2204
1198
  {
2205
1199
  message_index: (input_line_number + $base_idx),
2206
1200
  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
- ),
1201
+ 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),
1202
+ 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
1203
  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
- )
1204
+ 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
1205
  }
2235
1206
  else empty end
2236
1207
  ' 2>/dev/null | jq -s '.' 2>/dev/null)
2237
1208
 
2238
1209
  if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
2239
1210
  printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
2240
- echo '{}'
2241
- exit 0
1211
+ echo '{}'; exit 0
2242
1212
  fi
2243
1213
 
2244
- BODY=$(jq -n \\
2245
- --arg repo "$GIT_REPO" \\
2246
- --arg sid "$SESSION_ID" \\
2247
- --argjson messages "$MESSAGES" \\
1214
+ BODY=$(jq -n --arg repo "$GIT_REPO" --arg sid "$SESSION_ID" --argjson messages "$MESSAGES" \\
2248
1215
  '{repo: $repo, sessions: [{cc_session_id: $sid, messages: $messages}]}')
2249
1216
 
2250
- # Fire-and-forget \u2014 background the curl so we don't block the user
2251
1217
  (
2252
1218
  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
1219
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1220
+ -d "$BODY" --max-time 10 >/dev/null 2>&1
2258
1221
  ) &
2259
1222
  disown 2>/dev/null || true
2260
1223
 
2261
- # Update offset
2262
1224
  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
- }
1225
+ echo '{}'; exit 0
2350
1226
  `;
2351
1227
  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
1228
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2356
- # shellcheck disable=SC1091
2357
1229
  . "$SCRIPT_DIR/_synkro-common.sh"
2358
1230
 
2359
1231
  JWT=$(synkro_load_jwt)
@@ -2373,95 +1245,38 @@ GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2373
1245
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
2374
1246
  synkro_log "bashGuard checking: $CMD_SHORT"
2375
1247
 
2376
- TOOL_INPUT=$(jq -n --arg cmd "$COMMAND" '{command: $cmd}')
2377
-
2378
1248
  BODY=$(jq -n \\
2379
- --argjson tool_input "$TOOL_INPUT" \\
1249
+ --arg cmd "$COMMAND" \\
2380
1250
  --arg session_id "$SESSION_ID" \\
2381
1251
  --arg cwd "$CWD" \\
2382
1252
  --arg repo "$GIT_REPO" \\
2383
1253
  '{
2384
- kind: "bash_judge",
2385
- tool_input: $tool_input,
2386
- user_intent: null,
2387
- recent_user_messages: [],
2388
- recent_messages: [],
2389
- recent_actions: [],
1254
+ hook_event: "PreToolUse",
1255
+ tool_name: "Bash",
1256
+ tool_input: {command: $cmd},
1257
+ response_format: "cursor",
2390
1258
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
2391
1259
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
2392
- repo: (if ($repo | length) > 0 then $repo else null end),
2393
- ide: "cursor"
1260
+ repo: (if ($repo | length) > 0 then $repo else null end)
2394
1261
  }')
2395
1262
 
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
1263
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
2411
1264
 
2412
- if [ -z "$VERDICT" ]; then
1265
+ if [ -z "$RESP" ]; then
2413
1266
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
2414
- echo '{}'
2415
- exit 0
1267
+ echo '{}'; exit 0
2416
1268
  fi
2417
1269
 
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}"
1270
+ # Server returns cursor-format directly in hook_response
1271
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1272
+ echo "$RESP" | jq -c '.hook_response'
1273
+ else
1274
+ echo '{}'
2437
1275
  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
1276
+ exit 0
2458
1277
  `;
2459
1278
  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
1279
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2464
- # shellcheck disable=SC1091
2465
1280
  . "$SCRIPT_DIR/_synkro-common.sh"
2466
1281
 
2467
1282
  JWT=$(synkro_load_jwt)
@@ -2472,16 +1287,12 @@ PAYLOAD=$(cat)
2472
1287
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2473
1288
 
2474
1289
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
2475
-
2476
1290
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2477
1291
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2478
1292
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2479
1293
 
2480
1294
  FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
2481
1295
  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
1296
  if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2486
1297
 
2487
1298
  BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
@@ -2494,46 +1305,33 @@ BODY=$(jq -n \\
2494
1305
  --arg cwd "$CWD" \\
2495
1306
  --arg repo "$GIT_REPO" \\
2496
1307
  '{
1308
+ hook_event: "PreToolUse",
1309
+ tool_name: "Edit",
1310
+ tool_input: {file_path: $file_path, content: $content},
2497
1311
  file_path: $file_path,
2498
1312
  content: $content,
1313
+ response_format: "cursor",
2499
1314
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
2500
1315
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
2501
- repo: (if ($repo | length) > 0 then $repo else null end),
2502
- ide: "cursor"
1316
+ repo: (if ($repo | length) > 0 then $repo else null end)
2503
1317
  }')
2504
1318
 
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 "")
1319
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
2510
1320
 
2511
1321
  if [ -z "$RESP" ]; then
2512
1322
  synkro_log "editGuard $BASENAME \u2192 error (timeout)"
2513
- echo '{}'
2514
- exit 0
1323
+ echo '{}'; exit 0
2515
1324
  fi
2516
1325
 
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
1326
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1327
+ echo "$RESP" | jq -c '.hook_response'
1328
+ else
1329
+ echo '{}'
1330
+ fi
1331
+ exit 0
2530
1332
  `;
2531
1333
  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
1334
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2536
- # shellcheck disable=SC1091
2537
1335
  . "$SCRIPT_DIR/_synkro-common.sh"
2538
1336
 
2539
1337
  JWT=$(synkro_load_jwt)
@@ -2550,19 +1348,11 @@ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2550
1348
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2551
1349
  BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
2552
1350
 
2553
- # Read full file content for edit scan
1351
+ FULL_PATH="$FILE_PATH"
1352
+ [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
2554
1353
  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
1354
+ [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
2564
1355
 
2565
- # Extract deps from nearest package.json
2566
1356
  DEPS_JSON="{}"
2567
1357
  _PKG_DIR="\${CWD:-.}"
2568
1358
  while [ "$_PKG_DIR" != "/" ]; do
@@ -2575,30 +1365,18 @@ done
2575
1365
 
2576
1366
  synkro_log "editScan $BASENAME"
2577
1367
 
2578
- # Fire-and-forget: edit scan + CVE scan in background
2579
1368
  (
2580
1369
  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" \\
1370
+ --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
1371
+ --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
2586
1372
  --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
1373
+ '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
1374
+ + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
1375
+ + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
1376
+ + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
1377
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1378
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1379
+ -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
2602
1380
  ) &
2603
1381
  disown 2>/dev/null || true
2604
1382
 
@@ -2606,24 +1384,15 @@ echo '{}'
2606
1384
  exit 0
2607
1385
  `;
2608
1386
  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
1387
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2613
- # shellcheck disable=SC1091
2614
1388
  . "$SCRIPT_DIR/_synkro-common.sh"
2615
1389
 
2616
1390
  JWT=$(synkro_load_jwt)
2617
1391
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2618
1392
 
2619
1393
  PAYLOAD=$(cat)
2620
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2621
-
2622
1394
  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
1395
+ case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
2627
1396
 
2628
1397
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2629
1398
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
@@ -2631,12 +1400,10 @@ TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
2631
1400
  if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
2632
1401
  (
2633
1402
  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
1403
+ '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid}')
1404
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1405
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1406
+ -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
2640
1407
  ) &
2641
1408
  disown 2>/dev/null || true
2642
1409
  fi
@@ -4833,7 +3600,7 @@ function writeConfigEnv(opts) {
4833
3600
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
4834
3601
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
4835
3602
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
4836
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.14")}`
3603
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.16")}`
4837
3604
  ];
4838
3605
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
4839
3606
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);