@synkro-sh/cli 1.4.15 → 1.4.17

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
@@ -495,6 +495,86 @@ synkro_detect_repo() {
495
495
  echo ""
496
496
  }
497
497
 
498
+ synkro_channel_up() {
499
+ (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
500
+ }
501
+
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
+ . "$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
+ }
518
+
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
+ LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
550
+ if [ "$LOCAL_OK" = "false" ]; then
551
+ local fv
552
+ fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
553
+ LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
554
+ [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
555
+ LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
556
+ LOCAL_CAT=$(printf '%s' "$fv" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
557
+ LOCAL_SEV="\${LOCAL_SEV:-high}"; LOCAL_CAT="\${LOCAL_CAT:-policy_violation}"
558
+ fi
559
+ }
560
+
561
+ # Fire anonymized telemetry for local verdicts. All args positional.
562
+ synkro_capture_local() {
563
+ local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
564
+ (
565
+ BODY=$(jq -n \\
566
+ --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
567
+ --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
568
+ --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" \\
569
+ '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,model:"claude-sonnet-4-6",tool_name:$tn}
570
+ + (if $r != "" then {repo:$r} else {} end)
571
+ + (if $sid != "" then {session_id:$sid} else {} end)')
572
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
573
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
574
+ -d "$BODY" --max-time 2 >/dev/null 2>&1
575
+ ) &
576
+ }
577
+
498
578
  synkro_post_with_retry() {
499
579
  local url="$1" body="$2" timeout="\${3:-8}"
500
580
  local resp
@@ -545,44 +625,57 @@ if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
545
625
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
546
626
  synkro_log "bashGuard checking: $CMD_SHORT"
547
627
 
548
- # Extract transcript context
549
628
  TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
550
629
  USER_INTENT=""
551
630
  RECENT_USER_MESSAGES="[]"
552
- RECENT_MESSAGES="[]"
553
- RECENT_ACTIONS="[]"
554
631
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
555
- RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
556
- [.[] | select(.type == "user") | (.message.content
557
- | if type == "string" then . else (map(.text? // "") | join(" ")) end)
558
- | select(. != null and . != "")
559
- ] | .[-5:]' 2>/dev/null || echo "[]")
632
+ 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 "[]")
560
633
  USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
561
- RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
562
- [.[] | select(.type == "user" or .type == "assistant")
563
- | {type, text: (.message.content | if type == "string" then .[0:500]
564
- else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}
565
- ] | .[-10:]' 2>/dev/null || echo "[]")
566
- RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
567
- [.[] | select(.type == "assistant") | .message.content[]?
568
- | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}
569
- ] | .[-5:]' 2>/dev/null || echo "[]")
570
634
  fi
571
635
 
572
- # Extract CC model + usage from last assistant turn
636
+ # Headless detection
637
+ IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
638
+ case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
639
+
640
+ synkro_load_config
641
+ ROUTE=$(synkro_route)
642
+
643
+ if [ "$ROUTE" = "local" ]; then
644
+ # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
645
+ GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
646
+ trap "rm -f \\"$GRADER_FILE\\"" EXIT
647
+ printf 'Command: %s\\nUser intent: %s\\nOrg rules: %s\\n' "$COMMAND" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
648
+
649
+ CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" || echo "")
650
+ synkro_parse_local_verdict "$CC_RESP"
651
+
652
+ if [ "$LOCAL_OK" = "false" ]; then
653
+ if [ "$IS_HEADLESS" = "1" ]; then DEC="deny"; else DEC="ask"; fi
654
+ REASON="[synkro:local] bashGuard \u2192 block\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
655
+ jq -n --arg dec "$DEC" --arg reason "$REASON" --arg ctx "$REASON" \\
656
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$dec,permissionDecisionReason:$reason,additionalContext:$ctx}}'
657
+ synkro_capture_local "bash" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
658
+ else
659
+ jq -n --arg m "[synkro:local] bashGuard \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
660
+ synkro_capture_local "bash" "allow" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
661
+ fi
662
+ exit 0
663
+ fi
664
+
665
+ # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
573
666
  CC_MODEL=""
574
667
  CC_USAGE="{}"
668
+ RECENT_MESSAGES="[]"
669
+ RECENT_ACTIONS="[]"
670
+ SESSION_SUMMARY=""
575
671
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
576
672
  _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
577
673
  if [ -n "$_LAST" ]; then
578
674
  CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
579
675
  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 "{}")
580
676
  fi
581
- fi
582
-
583
- # Session summary from last summary entry
584
- SESSION_SUMMARY=""
585
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
677
+ 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 "[]")
678
+ 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 "[]")
586
679
  SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
587
680
  fi
588
681
 
@@ -664,6 +757,9 @@ if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
664
757
  FILE_SHORT=$(basename "$FILE_PATH")
665
758
  synkro_log "editGuard checking: $FILE_SHORT"
666
759
 
760
+ IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
761
+ case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
762
+
667
763
  # Read file before edit for reconstruction
668
764
  FILE_BEFORE=""
669
765
  if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
@@ -713,6 +809,32 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
713
809
  RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}] | .[-5:]' 2>/dev/null || echo "[]")
714
810
  fi
715
811
 
812
+ synkro_load_config
813
+ ROUTE=$(synkro_route)
814
+
815
+ if [ "$ROUTE" = "local" ]; then
816
+ # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
817
+ GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
818
+ trap "rm -f \\"$GRADER_FILE\\"" EXIT
819
+ 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"
820
+
821
+ CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" || echo "")
822
+ synkro_parse_local_verdict "$CC_RESP"
823
+
824
+ if [ "$LOCAL_OK" = "false" ]; then
825
+ if [ "$IS_HEADLESS" = "1" ]; then DEC="deny"; else DEC="ask"; fi
826
+ REASON="[synkro:local] editGuard $FILE_SHORT \u2192 block\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
827
+ jq -n --arg dec "$DEC" --arg reason "$REASON" --arg ctx "$REASON" \\
828
+ '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$dec,permissionDecisionReason:$reason,additionalContext:$ctx}}'
829
+ synkro_capture_local "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
830
+ else
831
+ jq -n --arg m "[synkro:local] editGuard $FILE_SHORT \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
832
+ synkro_capture_local "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
833
+ fi
834
+ exit 0
835
+ fi
836
+
837
+ # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
716
838
  BODY=$(jq -n \\
717
839
  --arg hook_event "PreToolUse" \\
718
840
  --arg tool_name "$TOOL_NAME" \\
@@ -829,6 +951,30 @@ while [ "$_PKG_DIR" != "/" ]; do
829
951
  _PKG_DIR=$(dirname "$_PKG_DIR")
830
952
  done
831
953
 
954
+ synkro_load_config
955
+ ROUTE=$(synkro_route)
956
+
957
+ if [ "$ROUTE" = "local" ]; then
958
+ # \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
959
+ GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
960
+ trap "rm -f \\"$GRADER_FILE\\"" EXIT
961
+ 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"
962
+
963
+ CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" || echo "")
964
+ synkro_parse_local_verdict "$CC_RESP"
965
+
966
+ if [ "$LOCAL_OK" = "false" ]; then
967
+ REASON="[synkro:local] editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
968
+ jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
969
+ synkro_capture_local "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
970
+ else
971
+ jq -n --arg m "[synkro:local] editScan $BASENAME \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
972
+ synkro_capture_local "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID"
973
+ fi
974
+ exit 0
975
+ fi
976
+
977
+ # \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
832
978
  BODY=$(jq -n \\
833
979
  --arg hook_event "PostToolUse" \\
834
980
  --arg tool_name "$TOOL_NAME" \\
@@ -863,7 +1009,6 @@ if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1;
863
1009
  exit 0
864
1010
  fi
865
1011
 
866
- # Server returns {hook_response: {...}} \u2014 extract and echo
867
1012
  if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
868
1013
  echo "$RESP" | jq -c '.hook_response'
869
1014
  else
@@ -3456,7 +3601,7 @@ function writeConfigEnv(opts) {
3456
3601
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3457
3602
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3458
3603
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3459
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.15")}`
3604
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.17")}`
3460
3605
  ];
3461
3606
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
3462
3607
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);