@synkro-sh/cli 1.4.15 → 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
@@ -495,6 +495,85 @@ 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
+ 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
+ }
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
+ 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
+
498
577
  synkro_post_with_retry() {
499
578
  local url="$1" body="$2" timeout="\${3:-8}"
500
579
  local resp
@@ -545,44 +624,57 @@ if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
545
624
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
546
625
  synkro_log "bashGuard checking: $CMD_SHORT"
547
626
 
548
- # Extract transcript context
549
627
  TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
550
628
  USER_INTENT=""
551
629
  RECENT_USER_MESSAGES="[]"
552
- RECENT_MESSAGES="[]"
553
- RECENT_ACTIONS="[]"
554
630
  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 "[]")
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 "[]")
560
632
  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
633
  fi
571
634
 
572
- # Extract CC model + usage from last assistant turn
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"
660
+ fi
661
+ exit 0
662
+ fi
663
+
664
+ # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
573
665
  CC_MODEL=""
574
666
  CC_USAGE="{}"
667
+ RECENT_MESSAGES="[]"
668
+ RECENT_ACTIONS="[]"
669
+ SESSION_SUMMARY=""
575
670
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
576
671
  _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
577
672
  if [ -n "$_LAST" ]; then
578
673
  CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
579
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 "{}")
580
675
  fi
581
- fi
582
-
583
- # Session summary from last summary entry
584
- SESSION_SUMMARY=""
585
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
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 "[]")
586
678
  SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
587
679
  fi
588
680
 
@@ -664,6 +756,9 @@ if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
664
756
  FILE_SHORT=$(basename "$FILE_PATH")
665
757
  synkro_log "editGuard checking: $FILE_SHORT"
666
758
 
759
+ IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
760
+ case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
761
+
667
762
  # Read file before edit for reconstruction
668
763
  FILE_BEFORE=""
669
764
  if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
@@ -713,6 +808,32 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
713
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 "[]")
714
809
  fi
715
810
 
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"
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
716
837
  BODY=$(jq -n \\
717
838
  --arg hook_event "PreToolUse" \\
718
839
  --arg tool_name "$TOOL_NAME" \\
@@ -829,6 +950,30 @@ while [ "$_PKG_DIR" != "/" ]; do
829
950
  _PKG_DIR=$(dirname "$_PKG_DIR")
830
951
  done
831
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
975
+
976
+ # \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
832
977
  BODY=$(jq -n \\
833
978
  --arg hook_event "PostToolUse" \\
834
979
  --arg tool_name "$TOOL_NAME" \\
@@ -863,7 +1008,6 @@ if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1;
863
1008
  exit 0
864
1009
  fi
865
1010
 
866
- # Server returns {hook_response: {...}} \u2014 extract and echo
867
1011
  if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
868
1012
  echo "$RESP" | jq -c '.hook_response'
869
1013
  else
@@ -3456,7 +3600,7 @@ function writeConfigEnv(opts) {
3456
3600
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
3457
3601
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
3458
3602
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
3459
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.15")}`
3603
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.16")}`
3460
3604
  ];
3461
3605
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
3462
3606
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);