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