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