@synkro-sh/cli 1.4.45 → 1.4.47
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 +1936 -1317
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -145,51 +145,40 @@ function installCCHooks(settingsPath, config) {
|
|
|
145
145
|
{
|
|
146
146
|
type: "command",
|
|
147
147
|
command: config.editPrecheckScriptPath,
|
|
148
|
-
timeout:
|
|
148
|
+
timeout: 30
|
|
149
149
|
}
|
|
150
150
|
],
|
|
151
151
|
[SYNKRO_MARKER]: true
|
|
152
152
|
});
|
|
153
153
|
settings.hooks.PreToolUse.push({
|
|
154
|
-
matcher: "ExitPlanMode",
|
|
155
|
-
hooks: [
|
|
156
|
-
{
|
|
157
|
-
type: "command",
|
|
158
|
-
command: config.planJudgeScriptPath,
|
|
159
|
-
timeout: 45
|
|
160
|
-
}
|
|
161
|
-
],
|
|
162
|
-
[SYNKRO_MARKER]: true
|
|
163
|
-
});
|
|
164
|
-
settings.hooks.PostToolUse.push({
|
|
165
154
|
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
166
155
|
hooks: [
|
|
167
156
|
{
|
|
168
157
|
type: "command",
|
|
169
|
-
command: config.
|
|
170
|
-
timeout:
|
|
158
|
+
command: config.cwePrecheckScriptPath,
|
|
159
|
+
timeout: 30
|
|
171
160
|
}
|
|
172
161
|
],
|
|
173
162
|
[SYNKRO_MARKER]: true
|
|
174
163
|
});
|
|
175
|
-
settings.hooks.
|
|
164
|
+
settings.hooks.PreToolUse.push({
|
|
176
165
|
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
177
166
|
hooks: [
|
|
178
167
|
{
|
|
179
168
|
type: "command",
|
|
180
|
-
command: config.
|
|
169
|
+
command: config.cvePrecheckScriptPath,
|
|
181
170
|
timeout: 10
|
|
182
171
|
}
|
|
183
172
|
],
|
|
184
173
|
[SYNKRO_MARKER]: true
|
|
185
174
|
});
|
|
186
|
-
settings.hooks.
|
|
187
|
-
matcher: "
|
|
175
|
+
settings.hooks.PreToolUse.push({
|
|
176
|
+
matcher: "ExitPlanMode",
|
|
188
177
|
hooks: [
|
|
189
178
|
{
|
|
190
179
|
type: "command",
|
|
191
|
-
command: config.
|
|
192
|
-
timeout:
|
|
180
|
+
command: config.planJudgeScriptPath,
|
|
181
|
+
timeout: 45
|
|
193
182
|
}
|
|
194
183
|
],
|
|
195
184
|
[SYNKRO_MARKER]: true
|
|
@@ -250,7 +239,7 @@ function uninstallCCHooks(settingsPath) {
|
|
|
250
239
|
if (!existsSync2(settingsPath)) return false;
|
|
251
240
|
const settings = readSettings(settingsPath);
|
|
252
241
|
if (!settings.hooks) return false;
|
|
253
|
-
const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop"];
|
|
242
|
+
const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop", "UserPromptSubmit"];
|
|
254
243
|
for (const evt of events) {
|
|
255
244
|
removeSynkroEntries(settings.hooks, evt);
|
|
256
245
|
}
|
|
@@ -475,7 +464,7 @@ var init_mcpConfig = __esm({
|
|
|
475
464
|
});
|
|
476
465
|
|
|
477
466
|
// cli/installer/hookScripts.ts
|
|
478
|
-
var SYNKRO_COMMON_SCRIPT,
|
|
467
|
+
var SYNKRO_COMMON_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
|
|
479
468
|
var init_hookScripts = __esm({
|
|
480
469
|
"cli/installer/hookScripts.ts"() {
|
|
481
470
|
"use strict";
|
|
@@ -499,6 +488,25 @@ synkro_load_jwt() {
|
|
|
499
488
|
}
|
|
500
489
|
|
|
501
490
|
synkro_refresh_jwt() {
|
|
491
|
+
# Lock via mkdir (atomic on all Unix including macOS \u2014 no flock needed)
|
|
492
|
+
local lockdir="\${CREDS_PATH}.lockdir"
|
|
493
|
+
if ! mkdir "$lockdir" 2>/dev/null; then
|
|
494
|
+
# Another hook is refreshing \u2014 wait and re-read
|
|
495
|
+
local _w=0
|
|
496
|
+
while [ -d "$lockdir" ] && [ $_w -lt 5 ]; do sleep 0.5; _w=$((_w+1)); done
|
|
497
|
+
JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
|
|
498
|
+
return 0
|
|
499
|
+
fi
|
|
500
|
+
trap "rmdir \\"$lockdir\\" 2>/dev/null" RETURN
|
|
501
|
+
|
|
502
|
+
# Re-check expiry \u2014 another hook may have just refreshed
|
|
503
|
+
local p2 exp2 now2
|
|
504
|
+
p2=$(printf '%s' "$JWT" | cut -d. -f2)
|
|
505
|
+
case $((\${#p2} % 4)) in 2) p2="\${p2}==";; 3) p2="\${p2}=";; esac
|
|
506
|
+
exp2=$(printf '%s' "$p2" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
|
|
507
|
+
now2=$(date -u +%s)
|
|
508
|
+
if [ $((exp2 - now2)) -ge 60 ]; then return 0; fi
|
|
509
|
+
|
|
502
510
|
local rt
|
|
503
511
|
rt=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
|
|
504
512
|
if [ -z "$rt" ]; then return 1; fi
|
|
@@ -514,7 +522,12 @@ synkro_refresh_jwt() {
|
|
|
514
522
|
new_rt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
|
|
515
523
|
[ -z "$new_rt" ] && new_rt="$rt"
|
|
516
524
|
local tmp="\${CREDS_PATH}.synkro.tmp"
|
|
517
|
-
|
|
525
|
+
local existing
|
|
526
|
+
existing=$(cat "$CREDS_PATH" 2>/dev/null)
|
|
527
|
+
if [ -z "$existing" ] || ! echo "$existing" | jq -e '.' >/dev/null 2>&1; then
|
|
528
|
+
existing='{}'
|
|
529
|
+
fi
|
|
530
|
+
echo "$existing" | jq --arg at "$new_at" --arg rt "$new_rt" '. + {access_token:$at,refresh_token:$rt}' > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
|
|
518
531
|
JWT="$new_at"
|
|
519
532
|
}
|
|
520
533
|
|
|
@@ -542,10 +555,6 @@ synkro_channel_up() {
|
|
|
542
555
|
(exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
|
|
543
556
|
}
|
|
544
557
|
|
|
545
|
-
synkro_cwe_channel_up() {
|
|
546
|
-
(exec 3<>/dev/tcp/127.0.0.1/8930) 2>/dev/null && exec 3<&- 3>&-
|
|
547
|
-
}
|
|
548
|
-
|
|
549
558
|
# Fetch hook config. Sets SYNKRO_CAPTURE_DEPTH, SYNKRO_TIER, SYNKRO_RULES, SYNKRO_SILENT, SYNKRO_POLICY_NAME.
|
|
550
559
|
synkro_load_config() {
|
|
551
560
|
local resp
|
|
@@ -558,8 +567,6 @@ synkro_load_config() {
|
|
|
558
567
|
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 "[]")
|
|
559
568
|
}
|
|
560
569
|
|
|
561
|
-
# Build the tag prefix: [synkro:route:ruleset] or [synkro:silent]
|
|
562
|
-
# Accepts optional $1 = route override; otherwise calls synkro_route().
|
|
563
570
|
synkro_tag() {
|
|
564
571
|
if [ "$SYNKRO_SILENT" = "true" ]; then echo "[synkro:silent]"; return; fi
|
|
565
572
|
local route="\${1:-$(synkro_route)}"
|
|
@@ -567,166 +574,12 @@ synkro_tag() {
|
|
|
567
574
|
echo "[synkro:\${route}:\${rs}]"
|
|
568
575
|
}
|
|
569
576
|
|
|
570
|
-
# Decide routing: "local" (grade on device) or "cloud" (POST to server)
|
|
571
577
|
synkro_route() {
|
|
572
578
|
[ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
|
|
573
579
|
synkro_channel_up && echo "local" && return
|
|
574
580
|
echo "cloud"
|
|
575
581
|
}
|
|
576
582
|
|
|
577
|
-
# Routing for CWE channel (port 8930).
|
|
578
|
-
synkro_cwe_route() {
|
|
579
|
-
[ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
|
|
580
|
-
synkro_cwe_channel_up && echo "local" && return
|
|
581
|
-
echo "cloud"
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
# Grade locally via synkro CLI channel. Reads prompt from stdin.
|
|
585
|
-
synkro_local_grade() {
|
|
586
|
-
local surface="$1"
|
|
587
|
-
if ! synkro_channel_up; then
|
|
588
|
-
echo "SYNKRO_CHANNEL_DOWN" >&2
|
|
589
|
-
return 1
|
|
590
|
-
fi
|
|
591
|
-
if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
|
|
592
|
-
node "$SYNKRO_CLI_BIN" grade "$surface" 2>/dev/null
|
|
593
|
-
elif command -v synkro >/dev/null 2>&1; then
|
|
594
|
-
synkro grade "$surface" 2>/dev/null
|
|
595
|
-
else
|
|
596
|
-
echo "SYNKRO_CLI_NOT_FOUND" >&2
|
|
597
|
-
return 1
|
|
598
|
-
fi
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
# Grade CWE locally via channel 2 (port 8930). Reads prompt from stdin.
|
|
602
|
-
synkro_local_grade_cwe() {
|
|
603
|
-
if ! synkro_cwe_channel_up; then
|
|
604
|
-
echo "SYNKRO_CHANNEL_DOWN" >&2
|
|
605
|
-
return 1
|
|
606
|
-
fi
|
|
607
|
-
if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
|
|
608
|
-
SYNKRO_CHANNEL_PORT=8930 node "$SYNKRO_CLI_BIN" grade cwe 2>/dev/null
|
|
609
|
-
elif command -v synkro >/dev/null 2>&1; then
|
|
610
|
-
SYNKRO_CHANNEL_PORT=8930 synkro grade cwe 2>/dev/null
|
|
611
|
-
else
|
|
612
|
-
echo "SYNKRO_CLI_NOT_FOUND" >&2
|
|
613
|
-
return 1
|
|
614
|
-
fi
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
# Extract the CC model from the current turn's transcript. Call after reading PAYLOAD.
|
|
618
|
-
# Sets CC_MODEL to the model used in the most recent assistant message.
|
|
619
|
-
synkro_detect_cc_model() {
|
|
620
|
-
CC_MODEL=""
|
|
621
|
-
local tp
|
|
622
|
-
tp=$(echo "\${1:-$PAYLOAD}" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
623
|
-
[ -z "$tp" ] || [ ! -f "$tp" ] && return
|
|
624
|
-
CC_MODEL=$(grep '"type":"assistant"' "$tp" 2>/dev/null | tail -1 | jq -r '.message.model // empty' 2>/dev/null)
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
# Parse <synkro-verdict>...</synkro-verdict> XML from local grader output.
|
|
628
|
-
# Sets LOCAL_OK, LOCAL_REASON, LOCAL_RULE_ID, LOCAL_RULE_MODE, LOCAL_SEV, LOCAL_CAT.
|
|
629
|
-
synkro_parse_local_verdict() {
|
|
630
|
-
local resp="$1"
|
|
631
|
-
LOCAL_OK="true"; LOCAL_REASON=""; LOCAL_RULE_ID=""; LOCAL_RULE_MODE=""; LOCAL_SEV="low"; LOCAL_CAT="clean"
|
|
632
|
-
local inner
|
|
633
|
-
inner=$(printf '%s' "$resp" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
|
|
634
|
-
[ -z "$inner" ] && return
|
|
635
|
-
local ok_tag
|
|
636
|
-
ok_tag=$(printf '%s' "$inner" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
|
|
637
|
-
[ -n "$ok_tag" ] && LOCAL_OK="$ok_tag"
|
|
638
|
-
LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
|
|
639
|
-
[ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reasoning>(.*)</reasoning>.*|\\1|p' | head -1)
|
|
640
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
641
|
-
LOCAL_RULE_ID=$(printf '%s' "$inner" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
|
|
642
|
-
LOCAL_RULE_MODE=$(printf '%s' "$inner" | sed -nE 's|.*<rule_mode>(.*)</rule_mode>.*|\\1|p' | head -1)
|
|
643
|
-
LOCAL_SEV=$(printf '%s' "$inner" | sed -nE 's|.*<risk_level>(.*)</risk_level>.*|\\1|p' | head -1)
|
|
644
|
-
if [ -z "$LOCAL_RULE_ID" ]; then
|
|
645
|
-
local fv
|
|
646
|
-
fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
|
|
647
|
-
LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
|
|
648
|
-
[ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
|
|
649
|
-
[ -z "$LOCAL_SEV" ] && LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
|
|
650
|
-
fi
|
|
651
|
-
LOCAL_SEV="\${LOCAL_SEV:-high}"
|
|
652
|
-
LOCAL_CAT=$(printf '%s' "$inner" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
|
|
653
|
-
LOCAL_CAT="\${LOCAL_CAT:-uncategorized}"
|
|
654
|
-
[ -z "$LOCAL_RULE_ID" ] && LOCAL_RULE_ID=$(printf '%s' "$LOCAL_REASON" | grep -oE '[Rr][0-9]{3}' | head -1)
|
|
655
|
-
fi
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
# Fire anonymized telemetry for local verdicts. All args positional.
|
|
659
|
-
synkro_capture_local() {
|
|
660
|
-
local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
|
|
661
|
-
local cc_model="\${CC_MODEL:-unknown}"
|
|
662
|
-
(
|
|
663
|
-
BODY=$(jq -n \\
|
|
664
|
-
--arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
|
|
665
|
-
--arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
|
|
666
|
-
--arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" --arg mdl "$cc_model" \\
|
|
667
|
-
'{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,cc_model:$mdl,model:$mdl,tool_name:$tn}
|
|
668
|
-
+ (if $r != "" then {repo:$r} else {} end)
|
|
669
|
-
+ (if $sid != "" then {session_id:$sid} else {} end)')
|
|
670
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
671
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
672
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1
|
|
673
|
-
) &
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
# Fire full-content telemetry for local verdicts (used when capture_depth is full or evidence_on_violation).
|
|
677
|
-
synkro_capture_local_full() {
|
|
678
|
-
local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
|
|
679
|
-
local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
|
|
680
|
-
local cc_model="\${CC_MODEL:-unknown}"
|
|
681
|
-
(
|
|
682
|
-
BODY=$(jq -n \\
|
|
683
|
-
--arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
|
|
684
|
-
--arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
|
|
685
|
-
--arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" --arg mdl "$cc_model" \\
|
|
686
|
-
--arg cmd "$command" --arg rsn "$reasoning" --arg cd "$SYNKRO_CAPTURE_DEPTH" \\
|
|
687
|
-
--argjson rc "$rules_checked" --argjson vr "$violated_rules" --argjson rum "$recent_user_messages" \\
|
|
688
|
-
'{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,
|
|
689
|
-
cc_model:$mdl,model:$mdl,tool_name:$tn,capture_depth:$cd,
|
|
690
|
-
command:(if ($cmd|length) > 0 then $cmd else null end),
|
|
691
|
-
reasoning:(if ($rsn|length) > 0 then $rsn else null end),
|
|
692
|
-
rules_checked:$rc, violated_rules:$vr, recent_user_messages:$rum}
|
|
693
|
-
+ (if $r != "" then {repo:$r} else {} end)
|
|
694
|
-
+ (if $sid != "" then {session_id:$sid} else {} end)')
|
|
695
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
696
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
697
|
-
-d "$BODY" --max-time 3 >/dev/null 2>&1
|
|
698
|
-
) &
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
# Dispatch local verdict capture based on capture_depth privacy setting.
|
|
702
|
-
# For full: always send full content. For evidence_on_violation: full only on violations. For local_only: anonymized only.
|
|
703
|
-
synkro_dispatch_capture() {
|
|
704
|
-
local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
|
|
705
|
-
local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
|
|
706
|
-
local send_full=false
|
|
707
|
-
case "\${SYNKRO_CAPTURE_DEPTH:-local_only}" in
|
|
708
|
-
full) send_full=true ;;
|
|
709
|
-
evidence_on_violation)
|
|
710
|
-
case "$verdict" in block|warning|deny) send_full=true ;; esac ;;
|
|
711
|
-
esac
|
|
712
|
-
if [ "$send_full" = "true" ]; then
|
|
713
|
-
synkro_capture_local_full "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id" \\
|
|
714
|
-
"$command" "$reasoning" "$rules_checked" "$violated_rules" "$recent_user_messages"
|
|
715
|
-
else
|
|
716
|
-
synkro_capture_local "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id"
|
|
717
|
-
fi
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
# Look up a rule's mode from cached rules. Returns "blocking" or "audit".
|
|
721
|
-
synkro_rule_mode() {
|
|
722
|
-
local rid="$1"
|
|
723
|
-
[ -z "$rid" ] || [ -z "\${SYNKRO_RULES:-}" ] && echo "blocking" && return
|
|
724
|
-
local m
|
|
725
|
-
m=$(printf '%s' "$SYNKRO_RULES" | jq -r --arg r "$rid" '[.[] | select(.rule_id == $r) | .mode] | if any(. == "blocking") then "blocking" else .[0] // "blocking" end' 2>/dev/null)
|
|
726
|
-
[ -z "$m" ] || [ "$m" = "null" ] && m="blocking"
|
|
727
|
-
echo "$m"
|
|
728
|
-
}
|
|
729
|
-
|
|
730
583
|
SYNKRO_CONSENT_FILE="$HOME/.synkro/.local-consent"
|
|
731
584
|
|
|
732
585
|
_TAB=$(printf '\\t')
|
|
@@ -750,25 +603,6 @@ synkro_consent_consume() {
|
|
|
750
603
|
awk -v p="$pat" -v r="$rep" '{if($0==p)print r;else print}' "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
|
|
751
604
|
}
|
|
752
605
|
|
|
753
|
-
synkro_consent_has_consumed() {
|
|
754
|
-
local sid="$1" hash="$2"
|
|
755
|
-
grep -q "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" 2>/dev/null
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
synkro_consent_clear_consumed() {
|
|
759
|
-
local sid="$1" hash="$2"
|
|
760
|
-
[ ! -f "$SYNKRO_CONSENT_FILE" ] && return
|
|
761
|
-
local tmp="\${SYNKRO_CONSENT_FILE}.tmp"
|
|
762
|
-
grep -v "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
SYNKRO_LAST_PROMPT_FILE="$HOME/.synkro/.last-prompt"
|
|
766
|
-
|
|
767
|
-
synkro_read_last_prompt() {
|
|
768
|
-
[ -f "$SYNKRO_LAST_PROMPT_FILE" ] || return
|
|
769
|
-
cat "$SYNKRO_LAST_PROMPT_FILE" 2>/dev/null
|
|
770
|
-
}
|
|
771
|
-
|
|
772
606
|
synkro_post_with_retry() {
|
|
773
607
|
local url="$1" body="$2" timeout="\${3:-8}"
|
|
774
608
|
local resp
|
|
@@ -787,7 +621,7 @@ synkro_post_with_retry() {
|
|
|
787
621
|
echo "$resp"
|
|
788
622
|
}
|
|
789
623
|
`;
|
|
790
|
-
|
|
624
|
+
CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
|
|
791
625
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
792
626
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
793
627
|
|
|
@@ -798,159 +632,52 @@ synkro_ensure_fresh_jwt
|
|
|
798
632
|
PAYLOAD=$(cat)
|
|
799
633
|
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
800
634
|
|
|
801
|
-
|
|
802
|
-
|
|
635
|
+
COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
|
|
636
|
+
if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
|
|
803
637
|
|
|
804
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
805
|
-
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
806
638
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
639
|
+
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
807
640
|
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
808
|
-
PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
|
|
809
|
-
synkro_detect_cc_model
|
|
810
|
-
|
|
811
|
-
# Translate tool calls to command string for logging
|
|
812
|
-
case "$TOOL_NAME" in
|
|
813
|
-
Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
|
|
814
|
-
Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
|
|
815
|
-
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)" ;;
|
|
816
|
-
Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
|
|
817
|
-
esac
|
|
818
|
-
if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
|
|
819
641
|
|
|
820
642
|
CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
|
|
821
643
|
synkro_log "bashGuard checking: $CMD_SHORT"
|
|
822
644
|
|
|
823
|
-
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
824
|
-
USER_INTENT=""
|
|
825
|
-
RECENT_USER_MESSAGES="[]"
|
|
826
|
-
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
827
|
-
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 "[]")
|
|
828
|
-
USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
|
|
829
|
-
fi
|
|
830
|
-
|
|
831
|
-
# Headless detection
|
|
832
|
-
IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
|
|
833
|
-
case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
|
|
834
|
-
|
|
835
|
-
LAST_PROMPT=$(synkro_read_last_prompt)
|
|
836
|
-
|
|
837
645
|
synkro_load_config
|
|
838
|
-
ROUTE=$(synkro_route)
|
|
839
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
840
|
-
|
|
841
646
|
if [ "$SYNKRO_SILENT" = "true" ]; then
|
|
842
|
-
|
|
843
|
-
exit 0
|
|
844
|
-
fi
|
|
845
|
-
|
|
846
|
-
if [ "$ROUTE" = "local" ]; then
|
|
847
|
-
# \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
|
|
848
|
-
GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
|
|
849
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
850
|
-
printf 'Working directory: %s\\nRepo: %s\\nCommand: %s\\nUser intent (last human message): %s\\nLast user prompt: %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$COMMAND" "\${USER_INTENT:-none stated}" "\${LAST_PROMPT:-none}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
|
|
851
|
-
|
|
852
|
-
CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" 2>&1)
|
|
853
|
-
if [ $? -ne 0 ]; then
|
|
854
|
-
jq -n --arg m "$TAG bashGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
|
|
855
|
-
exit 0
|
|
856
|
-
fi
|
|
857
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
858
|
-
|
|
859
|
-
# Build violated rules JSON for full-content capture
|
|
860
|
-
VIOLATED_JSON="[]"
|
|
861
|
-
[ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
862
|
-
|
|
863
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
864
|
-
RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
|
|
865
|
-
if [ "$RULE_MODE" = "audit" ]; then
|
|
866
|
-
REASON="$TAG bashGuard \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
|
|
867
|
-
jq -n --arg m "$REASON" '{systemMessage: $m}'
|
|
868
|
-
synkro_dispatch_capture "bash" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
869
|
-
"$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
|
|
870
|
-
else
|
|
871
|
-
REASON="$TAG bashGuard \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
|
|
872
|
-
jq -n --arg reason "$REASON" \\
|
|
873
|
-
'{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
|
|
874
|
-
synkro_dispatch_capture "bash" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
875
|
-
"$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
|
|
876
|
-
fi
|
|
877
|
-
else
|
|
878
|
-
jq -n --arg m "$TAG bashGuard \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
|
|
879
|
-
synkro_dispatch_capture "bash" "pass" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
880
|
-
"$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "\${RECENT_USER_MESSAGES:-[]}"
|
|
881
|
-
fi
|
|
882
|
-
exit 0
|
|
883
|
-
fi
|
|
884
|
-
|
|
885
|
-
# \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
886
|
-
CC_USAGE="{}"
|
|
887
|
-
RECENT_MESSAGES="[]"
|
|
888
|
-
RECENT_ACTIONS="[]"
|
|
889
|
-
SESSION_SUMMARY=""
|
|
890
|
-
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
891
|
-
_LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
|
|
892
|
-
if [ -n "$_LAST" ]; then
|
|
893
|
-
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 "{}")
|
|
894
|
-
fi
|
|
895
|
-
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 "[]")
|
|
896
|
-
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 "[]")
|
|
897
|
-
SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
|
|
647
|
+
echo '{}'; exit 0
|
|
898
648
|
fi
|
|
899
649
|
|
|
900
650
|
BODY=$(jq -n \\
|
|
901
|
-
--arg
|
|
902
|
-
--arg tool_name "$TOOL_NAME" \\
|
|
903
|
-
--argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
|
|
904
|
-
--arg user_intent "$USER_INTENT" \\
|
|
905
|
-
--argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
|
|
906
|
-
--argjson recent_messages "$RECENT_MESSAGES" \\
|
|
907
|
-
--argjson recent_actions "$RECENT_ACTIONS" \\
|
|
651
|
+
--arg cmd "$COMMAND" \\
|
|
908
652
|
--arg session_id "$SESSION_ID" \\
|
|
909
|
-
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
910
653
|
--arg cwd "$CWD" \\
|
|
911
654
|
--arg repo "$GIT_REPO" \\
|
|
912
|
-
--arg permission_mode "$PERMISSION_MODE" \\
|
|
913
|
-
--arg cc_model "$CC_MODEL" \\
|
|
914
|
-
--argjson cc_usage "$CC_USAGE" \\
|
|
915
|
-
--arg session_summary "$SESSION_SUMMARY" \\
|
|
916
|
-
--arg last_prompt "\${LAST_PROMPT:-}" \\
|
|
917
655
|
'{
|
|
918
|
-
hook_event:
|
|
919
|
-
tool_name:
|
|
920
|
-
tool_input: $
|
|
921
|
-
|
|
922
|
-
last_user_message: (if ($last_prompt | length) > 0 then $last_prompt else null end),
|
|
923
|
-
recent_user_messages: $recent_user_messages,
|
|
924
|
-
recent_messages: $recent_messages,
|
|
925
|
-
recent_actions: $recent_actions,
|
|
656
|
+
hook_event: "PreToolUse",
|
|
657
|
+
tool_name: "Bash",
|
|
658
|
+
tool_input: {command: $cmd},
|
|
659
|
+
response_format: "cursor",
|
|
926
660
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
927
|
-
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
928
661
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
929
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
930
|
-
permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
|
|
931
|
-
cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
|
|
932
|
-
cc_usage: $cc_usage,
|
|
933
|
-
session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
|
|
662
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
934
663
|
}')
|
|
935
664
|
|
|
936
|
-
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY"
|
|
665
|
+
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
|
|
937
666
|
|
|
938
667
|
if [ -z "$RESP" ]; then
|
|
939
668
|
synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
|
|
940
|
-
echo '{}'
|
|
941
|
-
exit 0
|
|
669
|
+
echo '{}'; exit 0
|
|
942
670
|
fi
|
|
943
671
|
|
|
944
|
-
|
|
945
|
-
|
|
672
|
+
# Server returns cursor-format directly in hook_response
|
|
673
|
+
if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
|
|
674
|
+
echo "$RESP" | jq -c '.hook_response'
|
|
675
|
+
else
|
|
946
676
|
echo '{}'
|
|
947
|
-
exit 0
|
|
948
677
|
fi
|
|
949
|
-
|
|
950
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
951
678
|
exit 0
|
|
952
679
|
`;
|
|
953
|
-
|
|
680
|
+
CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
|
|
954
681
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
955
682
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
956
683
|
|
|
@@ -962,245 +689,79 @@ PAYLOAD=$(cat)
|
|
|
962
689
|
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
963
690
|
|
|
964
691
|
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
965
|
-
case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
|
|
966
|
-
|
|
967
|
-
TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
|
|
968
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
969
|
-
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
970
692
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
693
|
+
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
971
694
|
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
972
|
-
PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
|
|
973
|
-
synkro_detect_cc_model
|
|
974
695
|
|
|
975
|
-
FILE_PATH=$(echo "$
|
|
696
|
+
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
|
|
697
|
+
CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
|
|
976
698
|
if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
977
699
|
|
|
978
|
-
|
|
979
|
-
synkro_log "editGuard checking: $
|
|
980
|
-
|
|
981
|
-
IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
|
|
982
|
-
case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
|
|
983
|
-
|
|
984
|
-
# Read file before edit for reconstruction
|
|
985
|
-
FILE_BEFORE=""
|
|
986
|
-
if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
|
|
987
|
-
FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
|
|
988
|
-
fi
|
|
989
|
-
|
|
990
|
-
# Reconstruct proposed content
|
|
991
|
-
case "$TOOL_NAME" in
|
|
992
|
-
Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
|
|
993
|
-
Edit|MultiEdit)
|
|
994
|
-
if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
|
|
995
|
-
PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
|
|
996
|
-
import os, json, sys
|
|
997
|
-
fb = os.environ.get("FILE_BEFORE_LITERAL", "")
|
|
998
|
-
ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
|
|
999
|
-
result = fb
|
|
1000
|
-
if "old_string" in ti and "new_string" in ti:
|
|
1001
|
-
if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
|
|
1002
|
-
elif "edits" in ti and isinstance(ti["edits"], list):
|
|
1003
|
-
for e in ti["edits"]:
|
|
1004
|
-
old = e.get("old_string", "") if isinstance(e, dict) else ""
|
|
1005
|
-
new = e.get("new_string", "") if isinstance(e, dict) else ""
|
|
1006
|
-
if old: result = result.replace(old, new, 1)
|
|
1007
|
-
sys.stdout.write(result)
|
|
1008
|
-
' 2>/dev/null)
|
|
1009
|
-
fi
|
|
1010
|
-
if [ -z "$PROPOSED" ]; then
|
|
1011
|
-
if [ "$TOOL_NAME" = "MultiEdit" ]; then
|
|
1012
|
-
PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
|
|
1013
|
-
else
|
|
1014
|
-
PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
|
|
1015
|
-
fi
|
|
1016
|
-
fi ;;
|
|
1017
|
-
NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
|
|
1018
|
-
esac
|
|
1019
|
-
if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
|
|
1020
|
-
|
|
1021
|
-
DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null)
|
|
1022
|
-
[ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
|
|
1023
|
-
|
|
1024
|
-
# Extract user intent from transcript
|
|
1025
|
-
USER_INTENT=""
|
|
1026
|
-
RECENT_USER_MESSAGES="[]"
|
|
1027
|
-
RECENT_MESSAGES="[]"
|
|
1028
|
-
RECENT_ACTIONS="[]"
|
|
1029
|
-
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
1030
|
-
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
1031
|
-
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 "[]")
|
|
1032
|
-
USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
|
|
1033
|
-
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 "[]")
|
|
1034
|
-
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 "[]")
|
|
1035
|
-
fi
|
|
1036
|
-
|
|
1037
|
-
LAST_PROMPT=$(synkro_read_last_prompt)
|
|
700
|
+
BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
|
|
701
|
+
synkro_log "editGuard checking: $BASENAME"
|
|
1038
702
|
|
|
1039
703
|
synkro_load_config
|
|
1040
|
-
ROUTE=$(synkro_route)
|
|
1041
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
1042
|
-
|
|
1043
704
|
if [ "$SYNKRO_SILENT" = "true" ]; then
|
|
1044
|
-
|
|
1045
|
-
exit 0
|
|
1046
|
-
fi
|
|
1047
|
-
|
|
1048
|
-
if [ "$ROUTE" = "local" ]; then
|
|
1049
|
-
# \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
|
|
1050
|
-
GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
|
|
1051
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
1052
|
-
printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nProposed content (first 4000 chars):\\n%s\\nUser intent (last human message): %s\\nLast user prompt: %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$PROPOSED" | head -c 4000)" "\${USER_INTENT:-none stated}" "\${LAST_PROMPT:-none}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
|
|
1053
|
-
|
|
1054
|
-
CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
|
|
1055
|
-
if [ $? -ne 0 ]; then
|
|
1056
|
-
jq -n --arg m "$TAG editGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
|
|
1057
|
-
exit 0
|
|
1058
|
-
fi
|
|
1059
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
1060
|
-
|
|
1061
|
-
# Build edit content description and violated rules for full-content capture
|
|
1062
|
-
EDIT_CONTENT="file=$FILE_PATH content=$(printf '%s' "$PROPOSED" | head -c 2000)"
|
|
1063
|
-
VIOLATED_JSON="[]"
|
|
1064
|
-
[ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
1065
|
-
|
|
1066
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
1067
|
-
RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
|
|
1068
|
-
if [ "$RULE_MODE" = "audit" ]; then
|
|
1069
|
-
REASON="$TAG editGuard $FILE_SHORT \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
|
|
1070
|
-
jq -n --arg m "$REASON" '{systemMessage: $m}'
|
|
1071
|
-
synkro_dispatch_capture "edit" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1072
|
-
"$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
|
|
1073
|
-
else
|
|
1074
|
-
REASON="$TAG editGuard $FILE_SHORT \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
|
|
1075
|
-
jq -n --arg reason "$REASON" \\
|
|
1076
|
-
'{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
|
|
1077
|
-
synkro_dispatch_capture "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1078
|
-
"$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
|
|
1079
|
-
fi
|
|
1080
|
-
else
|
|
1081
|
-
jq -n --arg m "$TAG editGuard $FILE_SHORT \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
|
|
1082
|
-
synkro_dispatch_capture "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1083
|
-
"$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
|
|
1084
|
-
fi
|
|
1085
|
-
exit 0
|
|
705
|
+
echo '{}'; exit 0
|
|
1086
706
|
fi
|
|
1087
707
|
|
|
1088
|
-
# \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
1089
708
|
BODY=$(jq -n \\
|
|
1090
|
-
--arg hook_event "PreToolUse" \\
|
|
1091
|
-
--arg tool_name "$TOOL_NAME" \\
|
|
1092
|
-
--argjson tool_input "$TOOL_INPUT" \\
|
|
1093
709
|
--arg file_path "$FILE_PATH" \\
|
|
1094
|
-
--arg content "$
|
|
1095
|
-
--arg file_before "$FILE_BEFORE" \\
|
|
1096
|
-
--argjson diff "$DIFF_FIELD" \\
|
|
1097
|
-
--arg user_intent "$USER_INTENT" \\
|
|
1098
|
-
--argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
|
|
1099
|
-
--argjson recent_messages "$RECENT_MESSAGES" \\
|
|
1100
|
-
--argjson recent_actions "$RECENT_ACTIONS" \\
|
|
710
|
+
--arg content "$CONTENT" \\
|
|
1101
711
|
--arg session_id "$SESSION_ID" \\
|
|
1102
|
-
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
1103
712
|
--arg cwd "$CWD" \\
|
|
1104
713
|
--arg repo "$GIT_REPO" \\
|
|
1105
|
-
--arg permission_mode "$PERMISSION_MODE" \\
|
|
1106
|
-
--arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
|
|
1107
|
-
--arg last_prompt "\${LAST_PROMPT:-}" \\
|
|
1108
714
|
'{
|
|
1109
|
-
hook_event:
|
|
1110
|
-
tool_name:
|
|
1111
|
-
tool_input: $
|
|
715
|
+
hook_event: "PreToolUse",
|
|
716
|
+
tool_name: "Edit",
|
|
717
|
+
tool_input: {file_path: $file_path, content: $content},
|
|
1112
718
|
file_path: $file_path,
|
|
1113
719
|
content: $content,
|
|
1114
|
-
|
|
1115
|
-
diff: $diff,
|
|
1116
|
-
user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
|
|
1117
|
-
last_user_message: (if ($last_prompt | length) > 0 then $last_prompt else null end),
|
|
1118
|
-
recent_user_messages: $recent_user_messages,
|
|
1119
|
-
recent_messages: $recent_messages,
|
|
1120
|
-
recent_actions: $recent_actions,
|
|
720
|
+
response_format: "cursor",
|
|
1121
721
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1122
|
-
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
1123
722
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1124
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1125
|
-
permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
|
|
1126
|
-
headless: ($headless_flag == "1")
|
|
723
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1127
724
|
}')
|
|
1128
725
|
|
|
1129
726
|
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
|
|
1130
727
|
|
|
1131
728
|
if [ -z "$RESP" ]; then
|
|
1132
|
-
synkro_log "editGuard $
|
|
1133
|
-
echo '{}'
|
|
1134
|
-
exit 0
|
|
1135
|
-
fi
|
|
1136
|
-
|
|
1137
|
-
if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
1138
|
-
synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
|
|
1139
|
-
echo '{}'
|
|
1140
|
-
exit 0
|
|
729
|
+
synkro_log "editGuard $BASENAME \u2192 error (timeout)"
|
|
730
|
+
echo '{}'; exit 0
|
|
1141
731
|
fi
|
|
1142
732
|
|
|
1143
|
-
|
|
1144
|
-
if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
|
|
1145
|
-
synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
|
|
733
|
+
if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
|
|
1146
734
|
echo "$RESP" | jq -c '.hook_response'
|
|
1147
735
|
else
|
|
1148
|
-
|
|
1149
|
-
if [ -n "$REASON" ]; then
|
|
1150
|
-
synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
|
|
1151
|
-
else
|
|
1152
|
-
synkro_log "editGuard $FILE_SHORT \u2192 pass"
|
|
1153
|
-
fi
|
|
1154
|
-
echo "$RESP" | jq -c '.hook_response // {}'
|
|
736
|
+
echo '{}'
|
|
1155
737
|
fi
|
|
1156
738
|
exit 0
|
|
1157
739
|
`;
|
|
1158
|
-
|
|
740
|
+
CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
|
|
1159
741
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1160
742
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1161
743
|
|
|
1162
744
|
JWT=$(synkro_load_jwt)
|
|
1163
745
|
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
1164
|
-
synkro_ensure_fresh_jwt
|
|
1165
746
|
|
|
1166
747
|
PAYLOAD=$(cat)
|
|
1167
748
|
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
1168
749
|
|
|
1169
|
-
|
|
1170
|
-
|
|
750
|
+
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
|
|
751
|
+
if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
1171
752
|
|
|
1172
|
-
|
|
1173
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.
|
|
1174
|
-
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
1175
|
-
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
753
|
+
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
|
|
754
|
+
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
1176
755
|
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
# Correction followup (backgrounded)
|
|
1180
|
-
if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
|
|
1181
|
-
(
|
|
1182
|
-
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"}')
|
|
1183
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1184
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1185
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1
|
|
1186
|
-
) &
|
|
1187
|
-
fi
|
|
1188
|
-
|
|
1189
|
-
# Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
|
|
1190
|
-
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
|
|
1191
|
-
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
1192
|
-
|
|
1193
|
-
BASENAME=$(basename "$FILE_PATH")
|
|
1194
|
-
synkro_log "editScan: $BASENAME"
|
|
1195
|
-
|
|
1196
|
-
FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
|
|
1197
|
-
if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
|
|
756
|
+
BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
|
|
1198
757
|
|
|
1199
|
-
|
|
1200
|
-
[ -
|
|
758
|
+
FULL_PATH="$FILE_PATH"
|
|
759
|
+
[ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
|
|
760
|
+
FULL_CONTENT=""
|
|
761
|
+
[ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
|
|
1201
762
|
|
|
1202
763
|
DEPS_JSON="{}"
|
|
1203
|
-
_PKG_DIR
|
|
764
|
+
_PKG_DIR="\${CWD:-.}"
|
|
1204
765
|
while [ "$_PKG_DIR" != "/" ]; do
|
|
1205
766
|
if [ -f "$_PKG_DIR/package.json" ]; then
|
|
1206
767
|
DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
|
|
@@ -1209,907 +770,1961 @@ while [ "$_PKG_DIR" != "/" ]; do
|
|
|
1209
770
|
_PKG_DIR=$(dirname "$_PKG_DIR")
|
|
1210
771
|
done
|
|
1211
772
|
|
|
1212
|
-
|
|
1213
|
-
ROUTE=$(synkro_route)
|
|
1214
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
1215
|
-
|
|
1216
|
-
if [ "$SYNKRO_SILENT" = "true" ]; then
|
|
1217
|
-
echo '{}'; exit 0
|
|
1218
|
-
fi
|
|
1219
|
-
|
|
1220
|
-
if [ "$ROUTE" = "local" ]; then
|
|
1221
|
-
# \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
|
|
1222
|
-
GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
|
|
1223
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
1224
|
-
printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nContent (first 4000 chars):\\n%s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
|
|
1225
|
-
|
|
1226
|
-
CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
|
|
1227
|
-
if [ $? -ne 0 ]; then
|
|
1228
|
-
echo '{}'; exit 0
|
|
1229
|
-
fi
|
|
1230
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
1231
|
-
|
|
1232
|
-
SCAN_CONTENT="file=$FILE_PATH"
|
|
1233
|
-
SCAN_VIOLATED="[]"
|
|
1234
|
-
[ -n "$LOCAL_RULE_ID" ] && SCAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
1235
|
-
|
|
1236
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
1237
|
-
RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
|
|
1238
|
-
if [ "$RULE_MODE" = "audit" ]; then
|
|
1239
|
-
REASON="$TAG editScan $BASENAME \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
|
|
1240
|
-
jq -n --arg m "$REASON" '{systemMessage: $m}'
|
|
1241
|
-
synkro_dispatch_capture "edit_scan" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1242
|
-
"$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
|
|
1243
|
-
else
|
|
1244
|
-
REASON="$TAG editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
|
|
1245
|
-
jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
|
|
1246
|
-
synkro_dispatch_capture "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1247
|
-
"$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
|
|
1248
|
-
fi
|
|
1249
|
-
else
|
|
1250
|
-
jq -n --arg m "$TAG editScan $BASENAME \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
|
|
1251
|
-
synkro_dispatch_capture "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1252
|
-
"$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
|
|
1253
|
-
fi
|
|
1254
|
-
exit 0
|
|
1255
|
-
fi
|
|
1256
|
-
|
|
1257
|
-
# \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
|
|
1258
|
-
BODY=$(jq -n \\
|
|
1259
|
-
--arg hook_event "PostToolUse" \\
|
|
1260
|
-
--arg tool_name "$TOOL_NAME" \\
|
|
1261
|
-
--argjson tool_input "$TOOL_INPUT" \\
|
|
1262
|
-
--arg file_path "$FILE_PATH" \\
|
|
1263
|
-
--arg content "$FILE_CONTENT" \\
|
|
1264
|
-
--argjson diff "$DIFF_FIELD" \\
|
|
1265
|
-
--argjson dependencies "$DEPS_JSON" \\
|
|
1266
|
-
--arg session_id "$SESSION_ID" \\
|
|
1267
|
-
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
1268
|
-
--arg cwd "$CWD" \\
|
|
1269
|
-
--arg repo "$GIT_REPO" \\
|
|
1270
|
-
'{
|
|
1271
|
-
hook_event: $hook_event,
|
|
1272
|
-
tool_name: $tool_name,
|
|
1273
|
-
tool_input: $tool_input,
|
|
1274
|
-
file_path: $file_path,
|
|
1275
|
-
content: $content,
|
|
1276
|
-
diff: $diff,
|
|
1277
|
-
dependencies: $dependencies,
|
|
1278
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1279
|
-
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
1280
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1281
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1282
|
-
}')
|
|
1283
|
-
|
|
1284
|
-
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
|
|
773
|
+
synkro_log "editScan $BASENAME"
|
|
1285
774
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
775
|
+
(
|
|
776
|
+
BODY=$(jq -n \\
|
|
777
|
+
--arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
|
|
778
|
+
--arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
|
|
779
|
+
--argjson deps "$DEPS_JSON" \\
|
|
780
|
+
'{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
|
|
781
|
+
+ (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
|
|
782
|
+
+ (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
|
|
783
|
+
+ (if ($repo | length) > 0 then {repo:$repo} else {} end)')
|
|
784
|
+
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
785
|
+
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
786
|
+
-d "$BODY" --max-time 10 >/dev/null 2>&1 || true
|
|
787
|
+
) &
|
|
788
|
+
disown 2>/dev/null || true
|
|
1291
789
|
|
|
1292
|
-
|
|
1293
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
1294
|
-
else
|
|
1295
|
-
echo '{}'
|
|
1296
|
-
fi
|
|
790
|
+
echo '{}'
|
|
1297
791
|
exit 0
|
|
1298
792
|
`;
|
|
1299
|
-
|
|
793
|
+
CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
|
|
1300
794
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1301
795
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1302
796
|
|
|
1303
797
|
JWT=$(synkro_load_jwt)
|
|
1304
798
|
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
1305
|
-
synkro_ensure_fresh_jwt
|
|
1306
799
|
|
|
1307
800
|
PAYLOAD=$(cat)
|
|
1308
|
-
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
1309
|
-
|
|
1310
801
|
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
# ExitPlanMode's tool_input contains {allowedPrompts:[...]}, not plan content.
|
|
1314
|
-
# Read from the most recently modified plan file instead.
|
|
1315
|
-
PLANS_DIR="$HOME/.claude/plans"
|
|
1316
|
-
PLAN_FILE=$(ls -t "$PLANS_DIR"/*.md 2>/dev/null | head -1)
|
|
1317
|
-
if [ -z "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ]; then echo '{}'; exit 0; fi
|
|
1318
|
-
PLAN=$(cat "$PLAN_FILE" 2>/dev/null)
|
|
1319
|
-
if [ -z "$PLAN" ] || [ \${#PLAN} -lt 20 ]; then echo '{}'; exit 0; fi
|
|
1320
|
-
|
|
1321
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
1322
|
-
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
1323
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
1324
|
-
synkro_detect_cc_model
|
|
1325
|
-
|
|
1326
|
-
PLAN_SHORT=$(printf '%s' "$PLAN" | head -c 80)
|
|
1327
|
-
synkro_log "planReview checking: $PLAN_SHORT..."
|
|
1328
|
-
|
|
1329
|
-
# Write review verdict into the plan file so it survives ExitPlanMode rejection
|
|
1330
|
-
append_review_to_plan() {
|
|
1331
|
-
local verdict="$1"
|
|
1332
|
-
local tmp="\${PLAN_FILE}.synkro.tmp"
|
|
1333
|
-
sed '/^<!-- synkro-plan-review -->$/,/^<!-- \\/synkro-plan-review -->$/d' "$PLAN_FILE" | sed -e :a -e '/^\\n*$/{$d;N;ba' -e '}' > "$tmp" 2>/dev/null
|
|
1334
|
-
printf '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \u2014 %s\\n\\n%s\\n\\n<!-- /synkro-plan-review -->\\n' "$(date '+%Y-%m-%d %H:%M')" "$verdict" >> "$tmp"
|
|
1335
|
-
mv "$tmp" "$PLAN_FILE" 2>/dev/null
|
|
1336
|
-
}
|
|
802
|
+
case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
|
|
1337
803
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
804
|
+
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
805
|
+
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
1341
806
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
807
|
+
IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
|
|
808
|
+
CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
809
|
+
CMD_HASH=""
|
|
810
|
+
if [ -n "$CMD" ]; then
|
|
811
|
+
CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
|
|
1345
812
|
fi
|
|
1346
813
|
|
|
1347
|
-
if [ "$
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
printf 'Working directory: %s\\nRepo: %s\\nPlan:\\n%s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$(printf '%s' "$PLAN" | head -c 8000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
|
|
1351
|
-
|
|
1352
|
-
CC_RESP=$(synkro_local_grade plan < "$GRADER_FILE" 2>&1)
|
|
1353
|
-
if [ $? -ne 0 ]; then
|
|
1354
|
-
echo '{}'; exit 0
|
|
1355
|
-
fi
|
|
1356
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
1357
|
-
|
|
1358
|
-
PLAN_CONTENT=$(printf '%s' "$PLAN" | head -c 2000)
|
|
1359
|
-
PLAN_VIOLATED="[]"
|
|
1360
|
-
[ -n "$LOCAL_RULE_ID" ] && PLAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
1361
|
-
|
|
1362
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
1363
|
-
VCOUNT=$(printf '%s' "$CC_RESP" | grep -c '<violation>' 2>/dev/null || echo "0")
|
|
1364
|
-
[ "$VCOUNT" = "0" ] && VCOUNT="1"
|
|
1365
|
-
REVIEW_MSG="\${VCOUNT} rule(s) relevant\${LOCAL_RULE_ID:+ (first: $LOCAL_RULE_ID)}: \${LOCAL_REASON:-check org rules during implementation}"
|
|
1366
|
-
append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REVIEW_MSG"
|
|
1367
|
-
MSG="$TAG planReview \u2192 $REVIEW_MSG"
|
|
1368
|
-
jq -n --arg m "$MSG" '{systemMessage: $m}'
|
|
1369
|
-
synkro_dispatch_capture "plan_review" "advisory" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
|
|
1370
|
-
"$PLAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$PLAN_VIOLATED" "[]"
|
|
814
|
+
if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
|
|
815
|
+
if [ "$IS_ERROR" = "false" ]; then
|
|
816
|
+
synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
|
|
1371
817
|
else
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
synkro_dispatch_capture "plan_review" "clean" "audit" "\${LOCAL_CAT:-general}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
|
|
1376
|
-
"$PLAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
|
|
818
|
+
if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
|
|
819
|
+
synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
|
|
820
|
+
fi
|
|
1377
821
|
fi
|
|
1378
|
-
exit 0
|
|
1379
822
|
fi
|
|
1380
823
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
tool_name: $tool_name,
|
|
1392
|
-
tool_input: {plan: $plan},
|
|
1393
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1394
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1395
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1396
|
-
}')
|
|
1397
|
-
|
|
1398
|
-
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
|
|
1399
|
-
|
|
1400
|
-
if [ -z "$RESP" ]; then
|
|
1401
|
-
synkro_log "planReview \u2192 error (timeout)"
|
|
1402
|
-
echo '{}'
|
|
1403
|
-
exit 0
|
|
1404
|
-
fi
|
|
1405
|
-
|
|
1406
|
-
if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
|
|
1407
|
-
echo '{}'
|
|
1408
|
-
exit 0
|
|
824
|
+
if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
|
|
825
|
+
(
|
|
826
|
+
BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
|
|
827
|
+
--argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
|
|
828
|
+
'{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
|
|
829
|
+
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
830
|
+
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
831
|
+
-d "$BODY" --max-time 3 >/dev/null 2>&1 || true
|
|
832
|
+
) &
|
|
833
|
+
disown 2>/dev/null || true
|
|
1409
834
|
fi
|
|
1410
835
|
|
|
1411
|
-
|
|
1412
|
-
HR=$(echo "$RESP" | jq -c '.hook_response')
|
|
1413
|
-
if echo "$HR" | jq -e '.hookSpecificOutput.permissionDecision' >/dev/null 2>&1; then
|
|
1414
|
-
REASON=$(echo "$HR" | jq -r '.hookSpecificOutput.permissionDecisionReason // "check org rules"' 2>/dev/null)
|
|
1415
|
-
append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REASON"
|
|
1416
|
-
jq -n --arg m "$TAG planReview \u2192 advisory: $REASON" '{systemMessage: $m}'
|
|
1417
|
-
else
|
|
1418
|
-
CLOUD_MSG=$(echo "$HR" | jq -r '.systemMessage // empty' 2>/dev/null)
|
|
1419
|
-
[ -n "$CLOUD_MSG" ] && append_review_to_plan "\u2705 $CLOUD_MSG"
|
|
1420
|
-
echo "$HR"
|
|
1421
|
-
fi
|
|
836
|
+
echo '{}'
|
|
1422
837
|
exit 0
|
|
1423
838
|
`;
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
839
|
+
}
|
|
840
|
+
});
|
|
1427
841
|
|
|
1428
|
-
|
|
1429
|
-
|
|
842
|
+
// cli/installer/hookScriptsTs.ts
|
|
843
|
+
var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, BASH_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS;
|
|
844
|
+
var init_hookScriptsTs = __esm({
|
|
845
|
+
"cli/installer/hookScriptsTs.ts"() {
|
|
846
|
+
"use strict";
|
|
847
|
+
SYNKRO_COMMON_TS = `
|
|
848
|
+
// Shared Synkro hook utilities \u2014 imported by all hook scripts.
|
|
849
|
+
import { readFileSync, writeFileSync, mkdirSync, rmdirSync, existsSync, renameSync } from 'node:fs';
|
|
850
|
+
import { join, dirname, basename, extname } from 'node:path';
|
|
851
|
+
import { homedir } from 'node:os';
|
|
852
|
+
import { execSync, spawn } from 'node:child_process';
|
|
1430
853
|
|
|
1431
|
-
|
|
1432
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
1433
|
-
if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
|
|
854
|
+
// \u2500\u2500\u2500 Config \u2500\u2500\u2500
|
|
1434
855
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
856
|
+
const HOME = homedir();
|
|
857
|
+
const CONFIG_PATH = join(HOME, '.synkro', 'config.env');
|
|
1438
858
|
|
|
1439
|
-
|
|
1440
|
-
if
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
--arg model "\${CC_MODEL:-unknown}" \\
|
|
1459
|
-
--arg cc_model "\${CC_MODEL:-}" \\
|
|
1460
|
-
--arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
|
|
1461
|
-
--argjson cc_usage "$CC_USAGE" \\
|
|
1462
|
-
'{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
|
|
1463
|
-
+ (if $repo != "" then {repo:$repo} else {} end)
|
|
1464
|
-
+ (if $session_id != "" then {session_id:$session_id} else {} end)
|
|
1465
|
-
+ (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
|
|
1466
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1467
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1468
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1
|
|
1469
|
-
fi
|
|
1470
|
-
) &
|
|
1471
|
-
fi
|
|
859
|
+
// Load config.env into process.env
|
|
860
|
+
if (existsSync(CONFIG_PATH)) {
|
|
861
|
+
try {
|
|
862
|
+
const lines = readFileSync(CONFIG_PATH, 'utf-8').split('\\n');
|
|
863
|
+
for (const line of lines) {
|
|
864
|
+
const trimmed = line.trim();
|
|
865
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
866
|
+
const eqIdx = trimmed.indexOf('=');
|
|
867
|
+
if (eqIdx < 1) continue;
|
|
868
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
869
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
870
|
+
// Strip surrounding quotes
|
|
871
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
872
|
+
val = val.slice(1, -1);
|
|
873
|
+
}
|
|
874
|
+
process.env[key] = val;
|
|
875
|
+
}
|
|
876
|
+
} catch {}
|
|
877
|
+
}
|
|
1472
878
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
879
|
+
export const GATEWAY_URL = process.env.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh';
|
|
880
|
+
export const CREDS_PATH = process.env.SYNKRO_CREDENTIALS_PATH || join(HOME, '.synkro', 'credentials.json');
|
|
881
|
+
const LAST_PROMPT_FILE = join(HOME, '.synkro', '.last-prompt');
|
|
1476
882
|
|
|
1477
|
-
|
|
883
|
+
// \u2500\u2500\u2500 Logging \u2500\u2500\u2500
|
|
1478
884
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
|
|
885
|
+
export function log(msg: string): void {
|
|
886
|
+
process.stderr.write('[synkro] ' + msg + '\\n');
|
|
887
|
+
}
|
|
1483
888
|
|
|
1484
|
-
|
|
889
|
+
// \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
|
|
1485
890
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
if
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
891
|
+
export function loadJwt(): string | null {
|
|
892
|
+
try {
|
|
893
|
+
if (!existsSync(CREDS_PATH)) return null;
|
|
894
|
+
const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
|
|
895
|
+
return creds.access_token || null;
|
|
896
|
+
} catch {
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
1493
900
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
.
|
|
901
|
+
function decodeJwtExp(jwt: string): number {
|
|
902
|
+
try {
|
|
903
|
+
const parts = jwt.split('.');
|
|
904
|
+
if (parts.length < 2) return 0;
|
|
905
|
+
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
906
|
+
while (payload.length % 4) payload += '=';
|
|
907
|
+
const decoded = Buffer.from(payload, 'base64').toString('utf-8');
|
|
908
|
+
const obj = JSON.parse(decoded);
|
|
909
|
+
return obj.exp || 0;
|
|
910
|
+
} catch {
|
|
911
|
+
return 0;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
1500
914
|
|
|
1501
|
-
|
|
915
|
+
export async function refreshJwt(jwt: string): Promise<string> {
|
|
916
|
+
try {
|
|
917
|
+
const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
|
|
918
|
+
const rt = creds.refresh_token;
|
|
919
|
+
if (!rt) return jwt;
|
|
1502
920
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
921
|
+
const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
|
|
922
|
+
method: 'POST',
|
|
923
|
+
headers: { 'Content-Type': 'application/json' },
|
|
924
|
+
body: JSON.stringify({ refresh_token: rt }),
|
|
925
|
+
signal: AbortSignal.timeout(4000),
|
|
926
|
+
});
|
|
927
|
+
const data = await resp.json() as any;
|
|
928
|
+
const newAt = data.access_token;
|
|
929
|
+
if (!newAt) return jwt;
|
|
930
|
+
|
|
931
|
+
const newRt = data.refresh_token || rt;
|
|
932
|
+
const existing = (() => {
|
|
933
|
+
try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
|
|
934
|
+
})();
|
|
935
|
+
const updated = { ...existing, access_token: newAt, refresh_token: newRt };
|
|
936
|
+
const tmp = CREDS_PATH + '.synkro.tmp';
|
|
937
|
+
writeFileSync(tmp, JSON.stringify(updated, null, 2));
|
|
938
|
+
renameSync(tmp, CREDS_PATH);
|
|
939
|
+
return newAt;
|
|
940
|
+
} catch {
|
|
941
|
+
return jwt;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
1509
944
|
|
|
1510
|
-
|
|
1511
|
-
if
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
-H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
|
|
1516
|
-
if [ -n "$RESP" ]; then
|
|
1517
|
-
SYNKRO_SILENT=$(echo "$RESP" | jq -r '.silent_mode // false' 2>/dev/null)
|
|
1518
|
-
SYNKRO_POLICY_NAME=$(echo "$RESP" | jq -r '.active_policy_name // empty' 2>/dev/null)
|
|
1519
|
-
fi
|
|
1520
|
-
fi
|
|
945
|
+
export async function ensureFreshJwt(jwt: string): Promise<string> {
|
|
946
|
+
if (!jwt) return jwt;
|
|
947
|
+
const exp = decodeJwtExp(jwt);
|
|
948
|
+
const now = Math.floor(Date.now() / 1000);
|
|
949
|
+
if (exp - now >= 60) return jwt;
|
|
1521
950
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
951
|
+
const lockdir = CREDS_PATH + '.lockdir';
|
|
952
|
+
let acquired = false;
|
|
953
|
+
try {
|
|
954
|
+
mkdirSync(lockdir);
|
|
955
|
+
acquired = true;
|
|
956
|
+
} catch {
|
|
957
|
+
// Another process is refreshing \u2014 wait and re-read
|
|
958
|
+
for (let i = 0; i < 5; i++) {
|
|
959
|
+
await new Promise(r => setTimeout(r, 500));
|
|
960
|
+
if (!existsSync(lockdir)) break;
|
|
961
|
+
}
|
|
962
|
+
// Re-read creds
|
|
963
|
+
const fresh = loadJwt();
|
|
964
|
+
return fresh || jwt;
|
|
965
|
+
}
|
|
1530
966
|
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
967
|
+
try {
|
|
968
|
+
// Re-check \u2014 another hook may have just refreshed
|
|
969
|
+
const freshJwt = loadJwt();
|
|
970
|
+
if (freshJwt) {
|
|
971
|
+
const freshExp = decodeJwtExp(freshJwt);
|
|
972
|
+
if (freshExp - Math.floor(Date.now() / 1000) >= 60) return freshJwt;
|
|
973
|
+
}
|
|
974
|
+
return await refreshJwt(jwt);
|
|
975
|
+
} finally {
|
|
976
|
+
if (acquired) {
|
|
977
|
+
try { rmdirSync(lockdir); } catch {}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
1535
981
|
|
|
1536
|
-
|
|
1537
|
-
if [ -n "$RESP" ]; then
|
|
1538
|
-
OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
|
|
1539
|
-
fi
|
|
982
|
+
// \u2500\u2500\u2500 Repo Detection \u2500\u2500\u2500
|
|
1540
983
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
CC_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
|
|
1554
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1555
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
984
|
+
export function detectRepo(cwd: string): string {
|
|
985
|
+
try {
|
|
986
|
+
const url = execSync('git remote get-url origin', { cwd, timeout: 3000, encoding: 'utf-8' }).trim();
|
|
987
|
+
if (!url) return '';
|
|
988
|
+
return url
|
|
989
|
+
.replace(/^git@[^:]+:/, '')
|
|
990
|
+
.replace(/^https?:\\/\\/[^/]+\\//, '')
|
|
991
|
+
.replace(/\\.git$/, '');
|
|
992
|
+
} catch {
|
|
993
|
+
return '';
|
|
994
|
+
}
|
|
995
|
+
}
|
|
1556
996
|
|
|
1557
|
-
|
|
1558
|
-
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
997
|
+
// \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
|
|
1559
998
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
999
|
+
export async function channelUp(port = 8929): Promise<boolean> {
|
|
1000
|
+
try {
|
|
1001
|
+
await fetch('http://127.0.0.1:' + port, { signal: AbortSignal.timeout(500) });
|
|
1002
|
+
return true;
|
|
1003
|
+
} catch (e: any) {
|
|
1004
|
+
// If we got a connection error vs a response error, check:
|
|
1005
|
+
// fetch throws TypeError for connection refused, but any HTTP response means the port is open
|
|
1006
|
+
if (e?.name === 'TimeoutError') return false;
|
|
1007
|
+
if (e?.cause?.code === 'ECONNREFUSED' || e?.code === 'ECONNREFUSED') return false;
|
|
1008
|
+
// Any other error (like bad response) means the port is actually open
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1563
1012
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1013
|
+
export async function cweChannelUp(): Promise<boolean> {
|
|
1014
|
+
return channelUp(8930);
|
|
1015
|
+
}
|
|
1567
1016
|
|
|
1568
|
-
|
|
1569
|
-
IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
|
|
1570
|
-
CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
1571
|
-
CMD_HASH=""
|
|
1572
|
-
if [ -n "$CMD" ]; then
|
|
1573
|
-
CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
|
|
1574
|
-
fi
|
|
1017
|
+
// \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
|
|
1575
1018
|
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1019
|
+
export interface Rule {
|
|
1020
|
+
rule_id: string;
|
|
1021
|
+
text: string;
|
|
1022
|
+
severity: string;
|
|
1023
|
+
category: string;
|
|
1024
|
+
mode: string;
|
|
1025
|
+
}
|
|
1579
1026
|
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
else
|
|
1588
|
-
if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
|
|
1589
|
-
synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
|
|
1590
|
-
fi
|
|
1591
|
-
fi
|
|
1592
|
-
fi
|
|
1027
|
+
export interface HookConfig {
|
|
1028
|
+
captureDepth: string;
|
|
1029
|
+
tier: string;
|
|
1030
|
+
silent: boolean;
|
|
1031
|
+
policyName: string;
|
|
1032
|
+
rules: Rule[];
|
|
1033
|
+
}
|
|
1593
1034
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1035
|
+
export async function loadConfig(jwt: string, query?: string): Promise<HookConfig> {
|
|
1036
|
+
const config: HookConfig = {
|
|
1037
|
+
captureDepth: 'local_only',
|
|
1038
|
+
tier: 'standard',
|
|
1039
|
+
silent: false,
|
|
1040
|
+
policyName: '',
|
|
1041
|
+
rules: [],
|
|
1042
|
+
};
|
|
1043
|
+
try {
|
|
1044
|
+
const url = GATEWAY_URL + '/api/v1/hook/config' + (query ? '?' + query : '');
|
|
1045
|
+
const resp = await fetch(url, {
|
|
1046
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
1047
|
+
signal: AbortSignal.timeout(4000),
|
|
1048
|
+
});
|
|
1049
|
+
const data = await resp.json() as any;
|
|
1050
|
+
config.captureDepth = data.capture_depth || 'local_only';
|
|
1051
|
+
config.tier = data.tier || 'standard';
|
|
1052
|
+
config.silent = data.silent_mode === true || data.silent_mode === 'true';
|
|
1053
|
+
config.policyName = data.active_policy_name || '';
|
|
1054
|
+
if (Array.isArray(data.rules)) {
|
|
1055
|
+
config.rules = data.rules
|
|
1056
|
+
.filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
|
|
1057
|
+
.map((r: any) => ({
|
|
1058
|
+
rule_id: r.rule_id || '',
|
|
1059
|
+
text: r.text || '',
|
|
1060
|
+
severity: r.severity || '',
|
|
1061
|
+
category: r.category || '',
|
|
1062
|
+
mode: r.mode || 'blocking',
|
|
1063
|
+
}));
|
|
1064
|
+
}
|
|
1065
|
+
} catch {}
|
|
1066
|
+
return config;
|
|
1067
|
+
}
|
|
1597
1068
|
|
|
1598
|
-
|
|
1599
|
-
exit 0
|
|
1600
|
-
`;
|
|
1601
|
-
CC_CVE_SCAN_SCRIPT = `#!/bin/bash
|
|
1602
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1603
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1069
|
+
// \u2500\u2500\u2500 Routing \u2500\u2500\u2500
|
|
1604
1070
|
|
|
1605
|
-
|
|
1606
|
-
if
|
|
1607
|
-
|
|
1071
|
+
export async function route(config: HookConfig): Promise<'local' | 'cloud'> {
|
|
1072
|
+
if (config.captureDepth === 'local_only') return 'local';
|
|
1073
|
+
if (await channelUp()) return 'local';
|
|
1074
|
+
return 'cloud';
|
|
1075
|
+
}
|
|
1608
1076
|
|
|
1609
|
-
|
|
1610
|
-
if
|
|
1077
|
+
export async function cweRoute(config: HookConfig): Promise<'local' | 'cloud'> {
|
|
1078
|
+
if (config.captureDepth === 'local_only') return 'local';
|
|
1079
|
+
if (await cweChannelUp()) return 'local';
|
|
1080
|
+
return 'cloud';
|
|
1081
|
+
}
|
|
1611
1082
|
|
|
1612
|
-
|
|
1613
|
-
case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
|
|
1083
|
+
// \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
|
|
1614
1084
|
|
|
1615
|
-
|
|
1616
|
-
|
|
1085
|
+
export function tag(rt: string, config: HookConfig): string {
|
|
1086
|
+
if (config.silent) return '[synkro:silent]';
|
|
1087
|
+
const rs = config.policyName || 'all';
|
|
1088
|
+
return '[synkro:' + rt + ':' + rs + ']';
|
|
1089
|
+
}
|
|
1617
1090
|
|
|
1618
|
-
|
|
1619
|
-
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
1091
|
+
// \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
|
|
1620
1092
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1093
|
+
function spawnGrade(surface: string, prompt: string, envOverride?: Record<string, string>, timeoutMs = 22000): Promise<string> {
|
|
1094
|
+
return new Promise((resolve, reject) => {
|
|
1095
|
+
const cliBin = process.env.SYNKRO_CLI_BIN;
|
|
1096
|
+
let cmd: string;
|
|
1097
|
+
let args: string[];
|
|
1623
1098
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
done
|
|
1099
|
+
if (cliBin && existsSync(cliBin)) {
|
|
1100
|
+
// Use the CLI binary directly with bun/node
|
|
1101
|
+
cmd = 'node';
|
|
1102
|
+
args = [cliBin, 'grade', surface];
|
|
1103
|
+
} else {
|
|
1104
|
+
cmd = 'synkro';
|
|
1105
|
+
args = ['grade', surface];
|
|
1106
|
+
}
|
|
1633
1107
|
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1108
|
+
const child = spawn(cmd, args, {
|
|
1109
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1110
|
+
env: { ...process.env, ...envOverride },
|
|
1111
|
+
});
|
|
1637
1112
|
|
|
1638
|
-
|
|
1113
|
+
let stdout = '';
|
|
1114
|
+
let stderr = '';
|
|
1115
|
+
child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
|
1116
|
+
child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
1117
|
+
child.stdin.write(prompt);
|
|
1118
|
+
child.stdin.end();
|
|
1119
|
+
|
|
1120
|
+
const timer = setTimeout(() => {
|
|
1121
|
+
child.kill();
|
|
1122
|
+
reject(new Error('SYNKRO_GRADE_TIMEOUT'));
|
|
1123
|
+
}, timeoutMs);
|
|
1124
|
+
|
|
1125
|
+
child.on('close', (code: number | null) => {
|
|
1126
|
+
clearTimeout(timer);
|
|
1127
|
+
if (code === 0) resolve(stdout);
|
|
1128
|
+
else reject(new Error(stderr || 'exit ' + code));
|
|
1129
|
+
});
|
|
1130
|
+
child.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1639
1133
|
|
|
1640
|
-
|
|
1134
|
+
export async function localGrade(surface: string, prompt: string): Promise<string> {
|
|
1135
|
+
if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
|
|
1136
|
+
return spawnGrade(surface, prompt);
|
|
1137
|
+
}
|
|
1641
1138
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1139
|
+
export async function localGradeCwe(prompt: string): Promise<string> {
|
|
1140
|
+
if (!(await cweChannelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
|
|
1141
|
+
return spawnGrade('cwe', prompt, { SYNKRO_CHANNEL_PORT: '8930' }, 12000);
|
|
1142
|
+
}
|
|
1644
1143
|
|
|
1645
|
-
|
|
1646
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1647
|
-
-d "$BODY" --max-time 6 2>/dev/null || echo "")
|
|
1144
|
+
// \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
|
|
1648
1145
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1146
|
+
export interface Verdict {
|
|
1147
|
+
ok: boolean;
|
|
1148
|
+
reason: string;
|
|
1149
|
+
ruleId: string;
|
|
1150
|
+
ruleMode: string;
|
|
1151
|
+
severity: string;
|
|
1152
|
+
category: string;
|
|
1153
|
+
}
|
|
1652
1154
|
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL}, \${CVE_CRIT} critical/high (\${CRIT_PKGS})" '{systemMessage: $m}'
|
|
1663
|
-
else
|
|
1664
|
-
[ "$ALL_TOTAL" -gt 3 ] && ALL_PKGS="\${ALL_PKGS}, ..."
|
|
1665
|
-
jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL} (\${ALL_PKGS})" '{systemMessage: $m}'
|
|
1666
|
-
fi
|
|
1667
|
-
else
|
|
1668
|
-
jq -n --arg m "[synkro:\${ROUTE}:cveScan] clean" '{systemMessage: $m}'
|
|
1669
|
-
fi
|
|
1670
|
-
exit 0
|
|
1671
|
-
`;
|
|
1672
|
-
CC_CWE_SCAN_SCRIPT = `#!/bin/bash
|
|
1673
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1674
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1155
|
+
export function parseVerdict(resp: string): Verdict {
|
|
1156
|
+
const verdict: Verdict = {
|
|
1157
|
+
ok: true,
|
|
1158
|
+
reason: '',
|
|
1159
|
+
ruleId: '',
|
|
1160
|
+
ruleMode: '',
|
|
1161
|
+
severity: 'low',
|
|
1162
|
+
category: 'clean',
|
|
1163
|
+
};
|
|
1675
1164
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1165
|
+
// Flatten newlines for easier regex
|
|
1166
|
+
const flat = resp.replace(/\\n/g, ' ');
|
|
1167
|
+
const outerMatch = flat.match(/<synkro-verdict>(.*)<\\/synkro-verdict>/);
|
|
1168
|
+
if (!outerMatch) return verdict;
|
|
1169
|
+
const inner = outerMatch[1];
|
|
1679
1170
|
|
|
1680
|
-
|
|
1681
|
-
if
|
|
1171
|
+
const okMatch = inner.match(/<ok>(.*?)<\\/ok>/);
|
|
1172
|
+
if (okMatch) verdict.ok = okMatch[1].trim() !== 'false';
|
|
1682
1173
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1174
|
+
const reasonMatch = inner.match(/<reason>(.*?)<\\/reason>/) || inner.match(/<reasoning>(.*?)<\\/reasoning>/);
|
|
1175
|
+
if (reasonMatch) verdict.reason = reasonMatch[1].trim();
|
|
1685
1176
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1177
|
+
if (!verdict.ok) {
|
|
1178
|
+
const ruleIdMatch = inner.match(/<rule_id>(.*?)<\\/rule_id>/);
|
|
1179
|
+
const ruleModeMatch = inner.match(/<rule_mode>(.*?)<\\/rule_mode>/);
|
|
1180
|
+
const sevMatch = inner.match(/<risk_level>(.*?)<\\/risk_level>/);
|
|
1181
|
+
|
|
1182
|
+
if (ruleIdMatch) {
|
|
1183
|
+
verdict.ruleId = ruleIdMatch[1].trim();
|
|
1184
|
+
} else {
|
|
1185
|
+
// Try to find inside a <violation> block
|
|
1186
|
+
const violationMatch = inner.match(/<violation>(.*?)<\\/violation>/);
|
|
1187
|
+
if (violationMatch) {
|
|
1188
|
+
const vBlock = violationMatch[1];
|
|
1189
|
+
const vRuleId = vBlock.match(/<rule_id>(.*?)<\\/rule_id>/);
|
|
1190
|
+
if (vRuleId) verdict.ruleId = vRuleId[1].trim();
|
|
1191
|
+
if (!verdict.reason) {
|
|
1192
|
+
const vReason = vBlock.match(/<reason>(.*?)<\\/reason>/);
|
|
1193
|
+
if (vReason) verdict.reason = vReason[1].trim();
|
|
1194
|
+
}
|
|
1195
|
+
if (!sevMatch) {
|
|
1196
|
+
const vSev = vBlock.match(/<severity>(.*?)<\\/severity>/);
|
|
1197
|
+
if (vSev) verdict.severity = vSev[1].trim();
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1688
1201
|
|
|
1689
|
-
|
|
1690
|
-
if
|
|
1202
|
+
if (ruleModeMatch) verdict.ruleMode = ruleModeMatch[1].trim();
|
|
1203
|
+
if (sevMatch) verdict.severity = sevMatch[1].trim();
|
|
1204
|
+
verdict.severity = verdict.severity || 'high';
|
|
1691
1205
|
|
|
1692
|
-
|
|
1693
|
-
|
|
1206
|
+
const catMatch = inner.match(/<category>(.*?)<\\/category>/);
|
|
1207
|
+
verdict.category = catMatch ? catMatch[1].trim() : 'uncategorized';
|
|
1694
1208
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1209
|
+
// Fallback: extract rule ID from reason text
|
|
1210
|
+
if (!verdict.ruleId && verdict.reason) {
|
|
1211
|
+
const rMatch = verdict.reason.match(/[Rr]\\d{3}/);
|
|
1212
|
+
if (rMatch) verdict.ruleId = rMatch[0];
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1698
1215
|
|
|
1699
|
-
|
|
1216
|
+
return verdict;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// \u2500\u2500\u2500 Telemetry Dispatch \u2500\u2500\u2500
|
|
1220
|
+
|
|
1221
|
+
export function dispatchCapture(
|
|
1222
|
+
jwt: string,
|
|
1223
|
+
hookType: string,
|
|
1224
|
+
verdictStr: string,
|
|
1225
|
+
severity: string,
|
|
1226
|
+
category: string,
|
|
1227
|
+
toolName: string,
|
|
1228
|
+
repo: string,
|
|
1229
|
+
sessionId: string,
|
|
1230
|
+
captureDepth: string,
|
|
1231
|
+
opts?: {
|
|
1232
|
+
command?: string;
|
|
1233
|
+
reasoning?: string;
|
|
1234
|
+
rulesChecked?: Rule[] | string;
|
|
1235
|
+
violatedRules?: string[];
|
|
1236
|
+
recentUserMessages?: string[];
|
|
1237
|
+
ccModel?: string;
|
|
1238
|
+
},
|
|
1239
|
+
): void {
|
|
1240
|
+
// Fire-and-forget
|
|
1241
|
+
const eventId = 'evt_' + Date.now() + '_' + process.pid;
|
|
1242
|
+
const model = opts?.ccModel || 'unknown';
|
|
1243
|
+
const sendFull =
|
|
1244
|
+
captureDepth === 'full' ||
|
|
1245
|
+
(captureDepth === 'evidence_on_violation' && ['block', 'warning', 'deny'].includes(verdictStr));
|
|
1246
|
+
|
|
1247
|
+
const body: Record<string, any> = {
|
|
1248
|
+
capture_type: 'local_verdict',
|
|
1249
|
+
event_id: eventId,
|
|
1250
|
+
hook_type: hookType,
|
|
1251
|
+
verdict: verdictStr,
|
|
1252
|
+
severity,
|
|
1253
|
+
category,
|
|
1254
|
+
cc_model: model,
|
|
1255
|
+
model,
|
|
1256
|
+
tool_name: toolName,
|
|
1257
|
+
};
|
|
1258
|
+
if (repo) body.repo = repo;
|
|
1259
|
+
if (sessionId) body.session_id = sessionId;
|
|
1700
1260
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1261
|
+
if (sendFull && opts) {
|
|
1262
|
+
body.capture_depth = captureDepth;
|
|
1263
|
+
if (opts.command) body.command = opts.command;
|
|
1264
|
+
if (opts.reasoning) body.reasoning = opts.reasoning;
|
|
1265
|
+
if (opts.rulesChecked) body.rules_checked = opts.rulesChecked;
|
|
1266
|
+
if (opts.violatedRules) body.violated_rules = opts.violatedRules;
|
|
1267
|
+
if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
|
|
1268
|
+
}
|
|
1703
1269
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] \${count} CWE \${label}, \${crit} critical (\${ids})" '{systemMessage: $m}'
|
|
1711
|
-
else
|
|
1712
|
-
[ "$total" -gt 3 ] && ids="\${ids}, ..."
|
|
1713
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] \${count} CWE \${label} (\${ids})" '{systemMessage: $m}'
|
|
1714
|
-
fi
|
|
1270
|
+
fetch(GATEWAY_URL + '/api/v1/hook/capture', {
|
|
1271
|
+
method: 'POST',
|
|
1272
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
1273
|
+
body: JSON.stringify(body),
|
|
1274
|
+
signal: AbortSignal.timeout(3000),
|
|
1275
|
+
}).catch(() => {});
|
|
1715
1276
|
}
|
|
1716
1277
|
|
|
1717
|
-
|
|
1718
|
-
# \u2500\u2500\u2500 Local CWE scan: deterministic Top 25 filter + local-cc grader \u2500\u2500\u2500
|
|
1719
|
-
CWE_RULES=$(curl -sS -X GET "\${GATEWAY_URL}/api/v1/cwe-rules?ext=$FILE_EXT" \\
|
|
1720
|
-
-H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
|
|
1721
|
-
CWE_LIST=$(echo "$CWE_RULES" | jq -c '.rules // []' 2>/dev/null || echo "[]")
|
|
1722
|
-
CWE_RULE_COUNT=$(echo "$CWE_LIST" | jq 'length' 2>/dev/null || echo "0")
|
|
1723
|
-
if [ "$CWE_RULE_COUNT" -eq 0 ] 2>/dev/null; then
|
|
1724
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean (no CWEs for $FILE_EXT)" '{systemMessage: $m}'
|
|
1725
|
-
exit 0
|
|
1726
|
-
fi
|
|
1727
|
-
|
|
1728
|
-
GRADER_FILE=$(mktemp -t synkro-cwescan.XXXXXX)
|
|
1729
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
1730
|
-
printf 'File: %s\\nContent (first 4000 chars):\\n%s\\n\\nCWE rules to check against:\\n%s\\n' "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "$CWE_LIST" > "$GRADER_FILE"
|
|
1278
|
+
// \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
|
|
1731
1279
|
|
|
1732
|
-
|
|
1733
|
-
if
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
1740
|
-
CWE_IDS=""
|
|
1741
|
-
CWE_COUNT=0
|
|
1742
|
-
CWE_CRIT=0
|
|
1743
|
-
while IFS= read -r vid; do
|
|
1744
|
-
[ -z "$vid" ] && continue
|
|
1745
|
-
CWE_COUNT=$((CWE_COUNT + 1))
|
|
1746
|
-
cwe_tag=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE "<violation>[^<]*<rule_id>$vid</rule_id>[^<]*<severity>[^<]*</severity>" | head -1)
|
|
1747
|
-
sev=$(printf '%s' "$cwe_tag" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p')
|
|
1748
|
-
[ "$sev" = "critical" ] && CWE_CRIT=$((CWE_CRIT + 1))
|
|
1749
|
-
CWE_ID=$(echo "$vid" | sed 's/cwe-/CWE-/')
|
|
1750
|
-
[ "$CWE_COUNT" -le 3 ] && { [ -n "$CWE_IDS" ] && CWE_IDS="$CWE_IDS, $CWE_ID" || CWE_IDS="$CWE_ID"; }
|
|
1751
|
-
done <<< "$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE '<rule_id>[^<]+</rule_id>' | sed 's/<[^>]*>//g')"
|
|
1752
|
-
|
|
1753
|
-
if [ "$CWE_COUNT" -gt 0 ]; then
|
|
1754
|
-
format_cwe_result "$CWE_COUNT" "$CWE_CRIT" "$CWE_IDS" "$CWE_COUNT"
|
|
1755
|
-
else
|
|
1756
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
|
|
1757
|
-
fi
|
|
1758
|
-
else
|
|
1759
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
|
|
1760
|
-
fi
|
|
1761
|
-
exit 0
|
|
1762
|
-
fi
|
|
1280
|
+
export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
|
|
1281
|
+
if (!ruleId || !rules.length) return 'blocking';
|
|
1282
|
+
const matched = rules.filter(r => r.rule_id === ruleId);
|
|
1283
|
+
if (matched.some(r => r.mode === 'blocking')) return 'blocking';
|
|
1284
|
+
return (matched[0]?.mode as 'blocking' | 'audit') || 'blocking';
|
|
1285
|
+
}
|
|
1763
1286
|
|
|
1764
|
-
|
|
1765
|
-
BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" \\
|
|
1766
|
-
'{file_path:$fp, content:$c}')
|
|
1287
|
+
// \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
|
|
1767
1288
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1289
|
+
export function reconstructContent(toolName: string, toolInput: any, filePath: string): string {
|
|
1290
|
+
switch (toolName) {
|
|
1291
|
+
case 'Write':
|
|
1292
|
+
return toolInput.content || '';
|
|
1293
|
+
case 'Edit': {
|
|
1294
|
+
let content = '';
|
|
1295
|
+
try {
|
|
1296
|
+
if (filePath && existsSync(filePath)) {
|
|
1297
|
+
content = readFileSync(filePath, 'utf-8').slice(0, 65536);
|
|
1298
|
+
}
|
|
1299
|
+
} catch {}
|
|
1300
|
+
const oldStr = toolInput.old_string || '';
|
|
1301
|
+
const newStr = toolInput.new_string || '';
|
|
1302
|
+
if (oldStr && content.includes(oldStr)) {
|
|
1303
|
+
return content.replace(oldStr, newStr);
|
|
1304
|
+
}
|
|
1305
|
+
return content || newStr;
|
|
1306
|
+
}
|
|
1307
|
+
case 'MultiEdit': {
|
|
1308
|
+
let content = '';
|
|
1309
|
+
try {
|
|
1310
|
+
if (filePath && existsSync(filePath)) {
|
|
1311
|
+
content = readFileSync(filePath, 'utf-8').slice(0, 65536);
|
|
1312
|
+
}
|
|
1313
|
+
} catch {}
|
|
1314
|
+
const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
|
|
1315
|
+
for (const edit of edits) {
|
|
1316
|
+
if (!edit || typeof edit !== 'object') continue;
|
|
1317
|
+
const old = edit.old_string || '';
|
|
1318
|
+
const nw = edit.new_string || '';
|
|
1319
|
+
if (old && content.includes(old)) {
|
|
1320
|
+
content = content.replace(old, nw);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return content;
|
|
1324
|
+
}
|
|
1325
|
+
case 'NotebookEdit':
|
|
1326
|
+
return toolInput.new_source || '';
|
|
1327
|
+
default:
|
|
1328
|
+
return '';
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1771
1331
|
|
|
1772
|
-
|
|
1773
|
-
echo '{}'; exit 0
|
|
1774
|
-
fi
|
|
1332
|
+
// \u2500\u2500\u2500 HTTP with Retry \u2500\u2500\u2500
|
|
1775
1333
|
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1334
|
+
export async function postWithRetry(url: string, body: any, jwt: string, timeout = 8000): Promise<any> {
|
|
1335
|
+
let currentJwt = jwt;
|
|
1336
|
+
let resp: Response;
|
|
1337
|
+
try {
|
|
1338
|
+
resp = await fetch(url, {
|
|
1339
|
+
method: 'POST',
|
|
1340
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + currentJwt },
|
|
1341
|
+
body: JSON.stringify(body),
|
|
1342
|
+
signal: AbortSignal.timeout(timeout),
|
|
1343
|
+
});
|
|
1344
|
+
} catch {
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1790
1347
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1348
|
+
let data: any;
|
|
1349
|
+
try { data = await resp.json(); } catch { return null; }
|
|
1793
1350
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1351
|
+
// Retry on token expiry
|
|
1352
|
+
if (data?.detail && (data.detail.includes('Token has expired') || data.detail.includes('Invalid or expired token'))) {
|
|
1353
|
+
try {
|
|
1354
|
+
currentJwt = await refreshJwt(currentJwt);
|
|
1355
|
+
const resp2 = await fetch(url, {
|
|
1356
|
+
method: 'POST',
|
|
1357
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + currentJwt },
|
|
1358
|
+
body: JSON.stringify(body),
|
|
1359
|
+
signal: AbortSignal.timeout(timeout),
|
|
1360
|
+
});
|
|
1361
|
+
data = await resp2.json();
|
|
1362
|
+
} catch {
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1798
1366
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
fi
|
|
1367
|
+
return data;
|
|
1368
|
+
}
|
|
1802
1369
|
|
|
1803
|
-
|
|
1804
|
-
_LAST_ASST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
|
|
1805
|
-
if [ -n "$_LAST_ASST" ]; then
|
|
1806
|
-
_CC_MODEL=$(echo "$_LAST_ASST" | jq -r '.message.model // empty' 2>/dev/null)
|
|
1807
|
-
_TI=$(echo "$_LAST_ASST" | jq -r '.message.usage.input_tokens // 0' 2>/dev/null)
|
|
1808
|
-
_TO=$(echo "$_LAST_ASST" | jq -r '.message.usage.output_tokens // 0' 2>/dev/null)
|
|
1809
|
-
_TCW=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_creation_input_tokens // 0' 2>/dev/null)
|
|
1810
|
-
_TCR=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_read_input_tokens // 0' 2>/dev/null)
|
|
1811
|
-
if [ "\${_TI:-0}" != "0" ] || [ "\${_TO:-0}" != "0" ]; then
|
|
1812
|
-
(
|
|
1813
|
-
_USAGE="{\\"input_tokens\\":$_TI,\\"output_tokens\\":$_TO,\\"cache_creation_input_tokens\\":$_TCW,\\"cache_read_input_tokens\\":$_TCR}"
|
|
1814
|
-
_BODY=$(jq -n \\
|
|
1815
|
-
--arg event_id "usage_$(date +%s)_$$" \\
|
|
1816
|
-
--arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
|
|
1817
|
-
--arg model "\${_CC_MODEL:-unknown}" \\
|
|
1818
|
-
--arg cc_model "\${_CC_MODEL:-}" \\
|
|
1819
|
-
--arg session_id "$SESSION_ID" \\
|
|
1820
|
-
--argjson cc_usage "$_USAGE" \\
|
|
1821
|
-
'{capture_type:"usage_tick",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage,session_id:$session_id}
|
|
1822
|
-
+ (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
|
|
1823
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1824
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1825
|
-
-d "$_BODY" --max-time 2 >/dev/null 2>&1
|
|
1826
|
-
) &
|
|
1827
|
-
fi
|
|
1828
|
-
fi
|
|
1370
|
+
// \u2500\u2500\u2500 Read Stdin \u2500\u2500\u2500
|
|
1829
1371
|
|
|
1830
|
-
|
|
1831
|
-
|
|
1372
|
+
export async function readStdin(): Promise<string> {
|
|
1373
|
+
const chunks: Buffer[] = [];
|
|
1374
|
+
for await (const chunk of process.stdin) {
|
|
1375
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1376
|
+
}
|
|
1377
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
1378
|
+
}
|
|
1832
1379
|
|
|
1833
|
-
|
|
1834
|
-
if [ -z "$GIT_REPO" ]; then echo '{}'; exit 0; fi
|
|
1380
|
+
// \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
|
|
1835
1381
|
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1382
|
+
export interface TranscriptContext {
|
|
1383
|
+
userIntent: string;
|
|
1384
|
+
recentUserMessages: string[];
|
|
1385
|
+
recentMessages: Array<{ type: string; text: string }>;
|
|
1386
|
+
recentActions: Array<{ tool: string; input: string }>;
|
|
1387
|
+
sessionSummary: string;
|
|
1388
|
+
ccModel: string;
|
|
1389
|
+
ccUsage: Record<string, any>;
|
|
1390
|
+
}
|
|
1840
1391
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
[
|
|
1392
|
+
export function extractTranscript(transcriptPath: string | undefined): TranscriptContext {
|
|
1393
|
+
const ctx: TranscriptContext = {
|
|
1394
|
+
userIntent: '',
|
|
1395
|
+
recentUserMessages: [],
|
|
1396
|
+
recentMessages: [],
|
|
1397
|
+
recentActions: [],
|
|
1398
|
+
sessionSummary: '',
|
|
1399
|
+
ccModel: '',
|
|
1400
|
+
ccUsage: {},
|
|
1401
|
+
};
|
|
1846
1402
|
|
|
1847
|
-
|
|
1848
|
-
if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then echo '{}'; exit 0; fi
|
|
1403
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return ctx;
|
|
1849
1404
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1405
|
+
try {
|
|
1406
|
+
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
1407
|
+
const lines = raw.split('\\n').filter(l => l.trim());
|
|
1408
|
+
// Take the last 400 lines
|
|
1409
|
+
const tail = lines.slice(-400);
|
|
1410
|
+
|
|
1411
|
+
const parsed: any[] = [];
|
|
1412
|
+
for (const line of tail) {
|
|
1413
|
+
try { parsed.push(JSON.parse(line)); } catch {}
|
|
1414
|
+
}
|
|
1853
1415
|
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1416
|
+
// Recent user messages (last 5)
|
|
1417
|
+
const userMsgs: string[] = [];
|
|
1418
|
+
for (const entry of parsed) {
|
|
1419
|
+
if (entry.type !== 'user') continue;
|
|
1420
|
+
const content = entry.message?.content;
|
|
1421
|
+
let text = '';
|
|
1422
|
+
if (typeof content === 'string') text = content;
|
|
1423
|
+
else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
|
|
1424
|
+
if (text) userMsgs.push(text);
|
|
1425
|
+
}
|
|
1426
|
+
ctx.recentUserMessages = userMsgs.slice(-5);
|
|
1427
|
+
ctx.userIntent = ctx.recentUserMessages[ctx.recentUserMessages.length - 1] || '';
|
|
1428
|
+
|
|
1429
|
+
// Recent messages (last 10, user + assistant)
|
|
1430
|
+
const msgs: Array<{ type: string; text: string }> = [];
|
|
1431
|
+
for (const entry of parsed) {
|
|
1432
|
+
if (entry.type !== 'user' && entry.type !== 'assistant') continue;
|
|
1433
|
+
const content = entry.message?.content;
|
|
1434
|
+
let text = '';
|
|
1435
|
+
if (typeof content === 'string') text = content.slice(0, 500);
|
|
1436
|
+
else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
|
|
1437
|
+
msgs.push({ type: entry.type, text });
|
|
1438
|
+
}
|
|
1439
|
+
ctx.recentMessages = msgs.slice(-10);
|
|
1440
|
+
|
|
1441
|
+
// Recent tool calls (last 5)
|
|
1442
|
+
const actions: Array<{ tool: string; input: string }> = [];
|
|
1443
|
+
for (const entry of parsed) {
|
|
1444
|
+
if (entry.type !== 'assistant') continue;
|
|
1445
|
+
const content = entry.message?.content;
|
|
1446
|
+
if (!Array.isArray(content)) continue;
|
|
1447
|
+
for (const block of content) {
|
|
1448
|
+
if (block.type !== 'tool_use') continue;
|
|
1449
|
+
actions.push({
|
|
1450
|
+
tool: block.name || '',
|
|
1451
|
+
input: JSON.stringify(block.input || {}).slice(0, 200),
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
ctx.recentActions = actions.slice(-5);
|
|
1456
|
+
|
|
1457
|
+
// Session summary
|
|
1458
|
+
for (const entry of parsed) {
|
|
1459
|
+
if (entry.type === 'summary' && entry.summary) {
|
|
1460
|
+
ctx.sessionSummary = entry.summary;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// CC model
|
|
1465
|
+
const assistantEntries = parsed.filter(e => e.type === 'assistant');
|
|
1466
|
+
if (assistantEntries.length > 0) {
|
|
1467
|
+
const last = assistantEntries[assistantEntries.length - 1];
|
|
1468
|
+
ctx.ccModel = last.message?.model || '';
|
|
1469
|
+
const usage = last.message?.usage;
|
|
1470
|
+
if (usage) {
|
|
1471
|
+
ctx.ccUsage = {
|
|
1472
|
+
input_tokens: usage.input_tokens,
|
|
1473
|
+
output_tokens: usage.output_tokens,
|
|
1474
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
|
1475
|
+
cache_read_input_tokens: usage.cache_read_input_tokens,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
} catch {}
|
|
1480
|
+
|
|
1481
|
+
return ctx;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// \u2500\u2500\u2500 Last Prompt \u2500\u2500\u2500
|
|
1485
|
+
|
|
1486
|
+
export function readLastPrompt(): string {
|
|
1487
|
+
try {
|
|
1488
|
+
if (!existsSync(LAST_PROMPT_FILE)) return '';
|
|
1489
|
+
return readFileSync(LAST_PROMPT_FILE, 'utf-8').trim();
|
|
1490
|
+
} catch {
|
|
1491
|
+
return '';
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// \u2500\u2500\u2500 Find Nearest Package Dependencies \u2500\u2500\u2500
|
|
1496
|
+
|
|
1497
|
+
export function findNearestDeps(filePath: string): Record<string, string> {
|
|
1498
|
+
let dir = dirname(filePath);
|
|
1499
|
+
while (dir !== '/' && dir !== '.') {
|
|
1500
|
+
const pkg = join(dir, 'package.json');
|
|
1501
|
+
if (existsSync(pkg)) {
|
|
1502
|
+
try {
|
|
1503
|
+
const data = JSON.parse(readFileSync(pkg, 'utf-8'));
|
|
1504
|
+
return { ...(data.dependencies || {}), ...(data.devDependencies || {}) };
|
|
1505
|
+
} catch {}
|
|
1506
|
+
}
|
|
1507
|
+
const parent = dirname(dir);
|
|
1508
|
+
if (parent === dir) break;
|
|
1509
|
+
dir = parent;
|
|
1510
|
+
}
|
|
1511
|
+
return {};
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// \u2500\u2500\u2500 Consent Tracking \u2500\u2500\u2500
|
|
1515
|
+
|
|
1516
|
+
const CONSENT_FILE = join(HOME, '.synkro', '.local-consent');
|
|
1517
|
+
|
|
1518
|
+
export function consentGrant(sessionId: string, hash: string): void {
|
|
1519
|
+
try {
|
|
1520
|
+
const dir = dirname(CONSENT_FILE);
|
|
1521
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1522
|
+
const line = sessionId + '\\t' + hash + '\\tactive\\n';
|
|
1523
|
+
const { appendFileSync } = require('node:fs');
|
|
1524
|
+
appendFileSync(CONSENT_FILE, line, 'utf-8');
|
|
1525
|
+
} catch {}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
export function consentHasActive(sessionId: string, hash: string): boolean {
|
|
1529
|
+
try {
|
|
1530
|
+
if (!existsSync(CONSENT_FILE)) return false;
|
|
1531
|
+
const content = readFileSync(CONSENT_FILE, 'utf-8');
|
|
1532
|
+
return content.includes(sessionId + '\\t' + hash + '\\tactive');
|
|
1533
|
+
} catch {
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
export function consentConsume(sessionId: string, hash: string): void {
|
|
1539
|
+
try {
|
|
1540
|
+
if (!existsSync(CONSENT_FILE)) return;
|
|
1541
|
+
const content = readFileSync(CONSENT_FILE, 'utf-8');
|
|
1542
|
+
const target = sessionId + '\\t' + hash + '\\tactive';
|
|
1543
|
+
const replacement = sessionId + '\\t' + hash + '\\tconsumed';
|
|
1544
|
+
const updated = content.split('\\n').map(l => l === target ? replacement : l).join('\\n');
|
|
1545
|
+
writeFileSync(CONSENT_FILE, updated, 'utf-8');
|
|
1546
|
+
} catch {}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// \u2500\u2500\u2500 Crypto Hash \u2500\u2500\u2500
|
|
1550
|
+
|
|
1551
|
+
export function hashCommand(cmd: string): string {
|
|
1552
|
+
const { createHash } = require('node:crypto');
|
|
1553
|
+
return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// \u2500\u2500\u2500 Transcript Usage Aggregation \u2500\u2500\u2500
|
|
1557
|
+
|
|
1558
|
+
export function aggregateUsage(transcriptPath: string): { model: string; totals: Record<string, number> } {
|
|
1559
|
+
const result = { model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } };
|
|
1560
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return result;
|
|
1561
|
+
try {
|
|
1562
|
+
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
1563
|
+
const lines = raw.split('\\n').filter(l => l.trim());
|
|
1564
|
+
for (const line of lines) {
|
|
1565
|
+
try {
|
|
1566
|
+
const entry = JSON.parse(line);
|
|
1567
|
+
if (entry.type !== 'assistant') continue;
|
|
1568
|
+
result.model = entry.message?.model || result.model;
|
|
1569
|
+
const u = entry.message?.usage;
|
|
1570
|
+
if (u) {
|
|
1571
|
+
result.totals.in += u.input_tokens || 0;
|
|
1572
|
+
result.totals.out += u.output_tokens || 0;
|
|
1573
|
+
result.totals.cw += u.cache_creation_input_tokens || 0;
|
|
1574
|
+
result.totals.cr += u.cache_read_input_tokens || 0;
|
|
1575
|
+
}
|
|
1576
|
+
} catch {}
|
|
1577
|
+
}
|
|
1578
|
+
} catch {}
|
|
1579
|
+
return result;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// \u2500\u2500\u2500 Output Helpers \u2500\u2500\u2500
|
|
1583
|
+
|
|
1584
|
+
export function outputJson(obj: any): void {
|
|
1585
|
+
console.log(JSON.stringify(obj));
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
export function outputEmpty(): void {
|
|
1589
|
+
console.log('{}');
|
|
1590
|
+
}
|
|
1591
|
+
`;
|
|
1592
|
+
EDIT_PRECHECK_TS = `#!/usr/bin/env bun
|
|
1593
|
+
import {
|
|
1594
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
1595
|
+
parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
|
|
1596
|
+
readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
|
|
1597
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
1598
|
+
type HookConfig, type Rule,
|
|
1599
|
+
} from './_synkro-common.ts';
|
|
1600
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
1601
|
+
import { basename, dirname, join } from 'node:path';
|
|
1602
|
+
|
|
1603
|
+
async function main() {
|
|
1604
|
+
try {
|
|
1605
|
+
const input = await readStdin();
|
|
1606
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1607
|
+
|
|
1608
|
+
const payload = JSON.parse(input);
|
|
1609
|
+
const toolName = payload.tool_name || '';
|
|
1610
|
+
if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1611
|
+
outputEmpty();
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const toolInput = payload.tool_input || {};
|
|
1616
|
+
const sessionId = payload.session_id || '';
|
|
1617
|
+
const toolUseId = payload.tool_use_id || '';
|
|
1618
|
+
const cwd = payload.cwd || '';
|
|
1619
|
+
const permissionMode = payload.permission_mode || '';
|
|
1620
|
+
const transcriptPath = payload.transcript_path || '';
|
|
1621
|
+
|
|
1622
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1623
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1624
|
+
|
|
1625
|
+
const fileShort = basename(filePath);
|
|
1626
|
+
log('editGuard checking: ' + fileShort);
|
|
1627
|
+
|
|
1628
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1629
|
+
|
|
1630
|
+
let jwt = loadJwt();
|
|
1631
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1632
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1633
|
+
|
|
1634
|
+
// Reconstruct proposed content
|
|
1635
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1636
|
+
if (!proposed) { outputEmpty(); return; }
|
|
1637
|
+
|
|
1638
|
+
// Build diff field
|
|
1639
|
+
let diffField: any = null;
|
|
1640
|
+
if (toolInput.old_string != null || toolInput.new_string != null || toolInput.edits != null) {
|
|
1641
|
+
diffField = {};
|
|
1642
|
+
if (toolInput.old_string != null) diffField.old_string = toolInput.old_string;
|
|
1643
|
+
if (toolInput.new_string != null) diffField.new_string = toolInput.new_string;
|
|
1644
|
+
if (toolInput.edits != null) diffField.edits = toolInput.edits;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Read file before edit for cloud payload
|
|
1648
|
+
let fileBefore = '';
|
|
1649
|
+
if (toolName !== 'Write' && filePath && existsSync(filePath)) {
|
|
1650
|
+
try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Extract transcript context
|
|
1654
|
+
const transcript = extractTranscript(transcriptPath);
|
|
1655
|
+
const lastPrompt = readLastPrompt();
|
|
1656
|
+
|
|
1657
|
+
// Load config and decide route
|
|
1658
|
+
const config = await loadConfig(jwt);
|
|
1659
|
+
const rt = await route(config);
|
|
1660
|
+
const tagStr = tag(rt, config);
|
|
1661
|
+
|
|
1662
|
+
if (config.silent) {
|
|
1663
|
+
outputJson({ systemMessage: tagStr + ' editGuard \\u2192 skipped (silent mode)' });
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (rt === 'local') {
|
|
1668
|
+
// \u2500\u2500\u2500 Local grading: org rules ONLY (channel 1, port 8929) \u2500\u2500\u2500
|
|
1669
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1670
|
+
const graderPrompt = [
|
|
1671
|
+
'Working directory: ' + (cwd || '.'),
|
|
1672
|
+
'Repo: ' + (gitRepo || 'unknown'),
|
|
1673
|
+
'File: ' + filePath,
|
|
1674
|
+
'Proposed content (first 4000 chars):',
|
|
1675
|
+
proposedShort,
|
|
1676
|
+
'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
|
|
1677
|
+
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
1678
|
+
'Org rules: ' + JSON.stringify(config.rules),
|
|
1679
|
+
].join('\\n');
|
|
1680
|
+
|
|
1681
|
+
let gradeResp: string;
|
|
1682
|
+
try {
|
|
1683
|
+
gradeResp = await localGrade('edit', graderPrompt);
|
|
1684
|
+
} catch {
|
|
1685
|
+
outputEmpty();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const verdict = parseVerdict(gradeResp);
|
|
1690
|
+
const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
|
|
1691
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
1692
|
+
|
|
1693
|
+
if (!verdict.ok) {
|
|
1694
|
+
const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
|
|
1695
|
+
const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
|
|
1696
|
+
|
|
1697
|
+
if (mode !== 'audit') {
|
|
1698
|
+
const denyReason = 'Guard: ' + guardReason + '\\nFix all issues before retrying.';
|
|
1699
|
+
dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
1700
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
1701
|
+
command: editContent, reasoning: guardReason,
|
|
1702
|
+
rulesChecked: config.rules, violatedRules,
|
|
1703
|
+
ccModel: transcript.ccModel,
|
|
1704
|
+
});
|
|
1705
|
+
outputJson({
|
|
1706
|
+
systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 blocked: ' + guardReason,
|
|
1707
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: denyReason },
|
|
1708
|
+
});
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Audit mode \u2014 warn but allow
|
|
1713
|
+
dispatchCapture(jwt, 'edit', 'warning', verdict.severity || 'medium', verdict.category || 'security',
|
|
1714
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
1715
|
+
command: editContent, reasoning: guardReason,
|
|
1716
|
+
rulesChecked: config.rules, violatedRules,
|
|
1717
|
+
ccModel: transcript.ccModel,
|
|
1718
|
+
});
|
|
1719
|
+
outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 warning: ' + guardReason });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Clean
|
|
1724
|
+
dispatchCapture(jwt, 'edit', 'pass', 'audit', verdict.category || 'trivial_edit',
|
|
1725
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
1726
|
+
command: editContent, reasoning: verdict.reason || 'no policy violations detected',
|
|
1727
|
+
rulesChecked: config.rules, violatedRules: [],
|
|
1728
|
+
ccModel: transcript.ccModel,
|
|
1729
|
+
});
|
|
1730
|
+
outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
1735
|
+
const deps = findNearestDeps(filePath);
|
|
1736
|
+
const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
|
|
1737
|
+
|| process.env.SYNKRO_HEADLESS === '1';
|
|
1738
|
+
|
|
1739
|
+
const body = {
|
|
1740
|
+
hook_event: 'PreToolUse',
|
|
1741
|
+
tool_name: toolName,
|
|
1742
|
+
tool_input: toolInput,
|
|
1743
|
+
file_path: filePath,
|
|
1744
|
+
content: proposed,
|
|
1745
|
+
file_before: fileBefore || null,
|
|
1746
|
+
diff: diffField,
|
|
1747
|
+
dependencies: deps,
|
|
1748
|
+
user_intent: transcript.userIntent || null,
|
|
1749
|
+
last_user_message: lastPrompt || null,
|
|
1750
|
+
recent_user_messages: transcript.recentUserMessages,
|
|
1751
|
+
recent_messages: transcript.recentMessages,
|
|
1752
|
+
recent_actions: transcript.recentActions,
|
|
1753
|
+
session_id: sessionId || null,
|
|
1754
|
+
tool_use_id: toolUseId || null,
|
|
1755
|
+
cwd: cwd || null,
|
|
1756
|
+
repo: gitRepo || null,
|
|
1757
|
+
permission_mode: permissionMode || null,
|
|
1758
|
+
headless: isHeadless,
|
|
1759
|
+
};
|
|
1760
|
+
|
|
1761
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
|
|
1762
|
+
|
|
1763
|
+
if (!resp) {
|
|
1764
|
+
log('editGuard ' + fileShort + ' \\u2192 error (timeout)');
|
|
1765
|
+
outputEmpty();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
if (!resp.hook_response || typeof resp.hook_response !== 'object') {
|
|
1770
|
+
log('editGuard ' + fileShort + ' \\u2192 pass (no hook_response)');
|
|
1771
|
+
outputEmpty();
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const hookResp = resp.hook_response;
|
|
1776
|
+
const decision = hookResp?.hookSpecificOutput?.permissionDecision;
|
|
1777
|
+
|
|
1778
|
+
if (decision === 'deny' || decision === 'ask') {
|
|
1779
|
+
log('editGuard ' + fileShort + ' \\u2192 BLOCKED');
|
|
1780
|
+
// Strip permissionDecision \u2014 we use systemMessage only
|
|
1781
|
+
const cleaned = { ...hookResp };
|
|
1782
|
+
if (cleaned.hookSpecificOutput) {
|
|
1783
|
+
cleaned.hookSpecificOutput = { ...cleaned.hookSpecificOutput };
|
|
1784
|
+
delete cleaned.hookSpecificOutput.permissionDecision;
|
|
1785
|
+
delete cleaned.hookSpecificOutput.permissionDecisionReason;
|
|
1786
|
+
}
|
|
1787
|
+
outputJson(cleaned);
|
|
1788
|
+
} else {
|
|
1789
|
+
const reason = hookResp.reason || '';
|
|
1790
|
+
log('editGuard ' + fileShort + ' \\u2192 pass' + (reason ? ': ' + reason : ''));
|
|
1791
|
+
outputJson(hookResp);
|
|
1792
|
+
}
|
|
1793
|
+
} catch (err) {
|
|
1794
|
+
process.stderr.write('[synkro] editGuard error: ' + String(err) + '\\n');
|
|
1795
|
+
outputEmpty();
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
main();
|
|
1800
|
+
`;
|
|
1801
|
+
CWE_PRECHECK_TS = `#!/usr/bin/env bun
|
|
1802
|
+
import {
|
|
1803
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
|
|
1804
|
+
localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
|
|
1805
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
1806
|
+
} from './_synkro-common.ts';
|
|
1807
|
+
import { basename, extname } from 'node:path';
|
|
1808
|
+
|
|
1809
|
+
async function main() {
|
|
1810
|
+
try {
|
|
1811
|
+
const input = await readStdin();
|
|
1812
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1813
|
+
|
|
1814
|
+
const payload = JSON.parse(input);
|
|
1815
|
+
const toolName = payload.tool_name || '';
|
|
1816
|
+
if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1817
|
+
outputEmpty();
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const toolInput = payload.tool_input || {};
|
|
1822
|
+
const sessionId = payload.session_id || '';
|
|
1823
|
+
const cwd = payload.cwd || '';
|
|
1824
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1825
|
+
|
|
1826
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1827
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1828
|
+
|
|
1829
|
+
const fileShort = basename(filePath);
|
|
1830
|
+
const fileExt = extname(filePath); // e.g. ".ts"
|
|
1831
|
+
|
|
1832
|
+
let jwt = loadJwt();
|
|
1833
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1834
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1835
|
+
|
|
1836
|
+
// Reconstruct proposed content
|
|
1837
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1838
|
+
if (!proposed) { outputEmpty(); return; }
|
|
1839
|
+
|
|
1840
|
+
const config = await loadConfig(jwt);
|
|
1841
|
+
const rt = await cweRoute(config);
|
|
1842
|
+
|
|
1843
|
+
if (config.silent) {
|
|
1844
|
+
outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const cweTag = '[synkro:' + rt + ':cweScan]';
|
|
1849
|
+
|
|
1850
|
+
if (rt === 'local') {
|
|
1851
|
+
// \u2500\u2500\u2500 Local CWE grading on channel 2 (port 8930) \u2500\u2500\u2500
|
|
1852
|
+
let cweRules: any[] = [];
|
|
1853
|
+
try {
|
|
1854
|
+
const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
|
|
1855
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
1856
|
+
signal: AbortSignal.timeout(4000),
|
|
1857
|
+
});
|
|
1858
|
+
const data = await resp.json() as any;
|
|
1859
|
+
cweRules = data.rules || [];
|
|
1860
|
+
} catch {}
|
|
1861
|
+
|
|
1862
|
+
if (cweRules.length === 0) {
|
|
1863
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (no CWE rules for ' + fileExt + ')' });
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1868
|
+
const graderPrompt = [
|
|
1869
|
+
'File: ' + filePath,
|
|
1870
|
+
'Content (first 4000 chars):',
|
|
1871
|
+
proposedShort,
|
|
1872
|
+
'',
|
|
1873
|
+
'CWE rules to check against:',
|
|
1874
|
+
JSON.stringify(cweRules),
|
|
1875
|
+
].join('\\n');
|
|
1876
|
+
|
|
1877
|
+
let gradeResp: string;
|
|
1878
|
+
try {
|
|
1879
|
+
gradeResp = await localGradeCwe(graderPrompt);
|
|
1880
|
+
} catch {
|
|
1881
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 grader unavailable, skipped' });
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
const verdict = parseVerdict(gradeResp);
|
|
1886
|
+
|
|
1887
|
+
if (!verdict.ok) {
|
|
1888
|
+
// Extract all CWE rule_ids from the raw response
|
|
1889
|
+
const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
|
|
1890
|
+
const cweIds: string[] = [];
|
|
1891
|
+
for (const match of ruleIdMatches.slice(0, 5)) {
|
|
1892
|
+
const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
|
|
1893
|
+
if (id && !cweIds.includes(id)) cweIds.push(id);
|
|
1894
|
+
}
|
|
1895
|
+
const displayIds = cweIds.slice(0, 3).join(', ');
|
|
1896
|
+
const count = cweIds.length;
|
|
1897
|
+
const label = count === 1 ? 'match' : 'matches';
|
|
1898
|
+
const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
|
|
1899
|
+
const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
|
|
1900
|
+
const ctx = 'CWE: ' + denyDetail + '\\nFix all issues before retrying.';
|
|
1901
|
+
|
|
1902
|
+
outputJson({
|
|
1903
|
+
systemMessage: cweMsg,
|
|
1904
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
|
|
1905
|
+
});
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// \u2500\u2500\u2500 Cloud CWE grading (handled by server) \u2500\u2500\u2500
|
|
1914
|
+
// Cloud edit precheck already includes CWE \u2014 this hook is a no-op for cloud.
|
|
1915
|
+
outputEmpty();
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
process.stderr.write('[synkro] cweGuard error: ' + String(err) + '\\n');
|
|
1918
|
+
outputEmpty();
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
main();
|
|
1923
|
+
`;
|
|
1924
|
+
CVE_PRECHECK_TS = `#!/usr/bin/env bun
|
|
1925
|
+
import {
|
|
1926
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
|
|
1927
|
+
reconstructContent, readStdin, findNearestDeps, log,
|
|
1928
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
1929
|
+
} from './_synkro-common.ts';
|
|
1930
|
+
import { basename } from 'node:path';
|
|
1931
|
+
|
|
1932
|
+
const MANIFEST_NAMES = new Set([
|
|
1933
|
+
'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
|
|
1934
|
+
'Pipfile', 'go.mod', 'go.sum', 'Gemfile', 'pom.xml', 'Cargo.toml', 'composer.json', 'pyproject.toml',
|
|
1935
|
+
]);
|
|
1936
|
+
|
|
1937
|
+
function isManifest(filename: string): boolean {
|
|
1938
|
+
if (MANIFEST_NAMES.has(filename)) return true;
|
|
1939
|
+
if (filename.startsWith('requirements') && filename.endsWith('.txt')) return true;
|
|
1940
|
+
if (filename.startsWith('build.gradle')) return true;
|
|
1941
|
+
if (filename.endsWith('.cabal')) return true;
|
|
1942
|
+
return false;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function main() {
|
|
1946
|
+
try {
|
|
1947
|
+
const input = await readStdin();
|
|
1948
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1949
|
+
|
|
1950
|
+
const payload = JSON.parse(input);
|
|
1951
|
+
const toolName = payload.tool_name || '';
|
|
1952
|
+
if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1953
|
+
outputEmpty();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const toolInput = payload.tool_input || {};
|
|
1958
|
+
const cwd = payload.cwd || '';
|
|
1959
|
+
|
|
1960
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1961
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1962
|
+
|
|
1963
|
+
const fileShort = basename(filePath);
|
|
1964
|
+
|
|
1965
|
+
let jwt = loadJwt();
|
|
1966
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1967
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1968
|
+
|
|
1969
|
+
const config = await loadConfig(jwt);
|
|
1970
|
+
const rt = await route(config);
|
|
1971
|
+
|
|
1972
|
+
if (config.silent) {
|
|
1973
|
+
outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const cveTag = '[synkro:' + rt + ':cveScan]';
|
|
1978
|
+
|
|
1979
|
+
// Reconstruct proposed content
|
|
1980
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1981
|
+
if (!proposed) {
|
|
1982
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1987
|
+
|
|
1988
|
+
// For code files, find nearest package.json and extract deps
|
|
1989
|
+
let deps: Record<string, string> = {};
|
|
1990
|
+
if (!isManifest(fileShort)) {
|
|
1991
|
+
deps = findNearestDeps(filePath);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// CVE scan via OSV API
|
|
1995
|
+
const cveBody = {
|
|
1996
|
+
file_path: filePath,
|
|
1997
|
+
content: proposedShort,
|
|
1998
|
+
dependencies: deps,
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
let cveResp: any;
|
|
2002
|
+
try {
|
|
2003
|
+
const resp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
|
|
2004
|
+
method: 'POST',
|
|
2005
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
2006
|
+
body: JSON.stringify(cveBody),
|
|
2007
|
+
signal: AbortSignal.timeout(8000),
|
|
2008
|
+
});
|
|
2009
|
+
cveResp = await resp.json();
|
|
2010
|
+
} catch {
|
|
2011
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 error (timeout)' });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
|
|
2016
|
+
if (findings.length > 0) {
|
|
2017
|
+
const top3 = findings.slice(0, 3).map((f: any) => {
|
|
2018
|
+
const id = f.cve || f.id || '?';
|
|
2019
|
+
const pkg = f.package || '?';
|
|
2020
|
+
const ver = f.version || '?';
|
|
2021
|
+
const title = f.title || f.summary || 'vulnerable';
|
|
2022
|
+
return '[' + id + '] ' + pkg + '@' + ver + ': ' + title;
|
|
2023
|
+
}).join('; ');
|
|
2024
|
+
|
|
2025
|
+
const count = findings.length;
|
|
2026
|
+
const label = count === 1 ? 'advisory' : 'advisories';
|
|
2027
|
+
const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
|
|
2028
|
+
const ctx = 'CVE: ' + top3 + '\\nFix all issues before retrying.';
|
|
2029
|
+
|
|
2030
|
+
outputJson({
|
|
2031
|
+
systemMessage: cveMsg,
|
|
2032
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
|
|
2033
|
+
});
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
|
|
2040
|
+
outputEmpty();
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
main();
|
|
2045
|
+
`;
|
|
2046
|
+
BASH_JUDGE_TS = `#!/usr/bin/env bun
|
|
2047
|
+
import {
|
|
2048
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
2049
|
+
parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
|
|
2050
|
+
extractTranscript, readLastPrompt, log,
|
|
2051
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2052
|
+
type HookConfig, type Rule,
|
|
2053
|
+
} from './_synkro-common.ts';
|
|
2054
|
+
|
|
2055
|
+
async function main() {
|
|
2056
|
+
try {
|
|
2057
|
+
const input = await readStdin();
|
|
2058
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2059
|
+
|
|
2060
|
+
const payload = JSON.parse(input);
|
|
2061
|
+
const toolName = payload.tool_name || '';
|
|
2062
|
+
if (!['Bash', 'Read', 'Grep', 'Glob'].includes(toolName)) {
|
|
2063
|
+
outputEmpty();
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
const toolInput = payload.tool_input || {};
|
|
2068
|
+
const sessionId = payload.session_id || '';
|
|
2069
|
+
const toolUseId = payload.tool_use_id || '';
|
|
2070
|
+
const cwd = payload.cwd || '';
|
|
2071
|
+
const permissionMode = payload.permission_mode || '';
|
|
2072
|
+
const transcriptPath = payload.transcript_path || '';
|
|
2073
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2074
|
+
|
|
2075
|
+
let command = '';
|
|
2076
|
+
switch (toolName) {
|
|
2077
|
+
case 'Bash': command = toolInput.command || ''; break;
|
|
2078
|
+
case 'Read': command = 'cat ' + (toolInput.file_path || ''); break;
|
|
2079
|
+
case 'Grep': command = "grep -r '" + (toolInput.pattern || '') + "' " + (toolInput.path || '.'); break;
|
|
2080
|
+
case 'Glob': command = "find . -name '" + (toolInput.pattern || '') + "'"; break;
|
|
2081
|
+
}
|
|
2082
|
+
if (!command) { outputEmpty(); return; }
|
|
2083
|
+
|
|
2084
|
+
const cmdShort = command.slice(0, 80);
|
|
2085
|
+
log('bashGuard checking: ' + cmdShort);
|
|
2086
|
+
|
|
2087
|
+
let jwt = loadJwt();
|
|
2088
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2089
|
+
jwt = await ensureFreshJwt(jwt);
|
|
2090
|
+
|
|
2091
|
+
const transcript = extractTranscript(transcriptPath);
|
|
2092
|
+
const lastPrompt = readLastPrompt();
|
|
2093
|
+
|
|
2094
|
+
const config = await loadConfig(jwt);
|
|
2095
|
+
const rt = await route(config);
|
|
2096
|
+
const tagStr = tag(rt, config);
|
|
2097
|
+
|
|
2098
|
+
if (config.silent) {
|
|
2099
|
+
outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 skipped (silent mode)' });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
if (rt === 'local') {
|
|
2104
|
+
const graderPrompt = [
|
|
2105
|
+
'Working directory: ' + (cwd || '.'),
|
|
2106
|
+
'Repo: ' + (gitRepo || 'unknown'),
|
|
2107
|
+
'Command: ' + command,
|
|
2108
|
+
'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
|
|
2109
|
+
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
2110
|
+
'Org rules: ' + JSON.stringify(config.rules),
|
|
2111
|
+
].join('\\n');
|
|
2112
|
+
|
|
2113
|
+
let gradeResp: string;
|
|
2114
|
+
try {
|
|
2115
|
+
gradeResp = await localGrade('bash', graderPrompt);
|
|
2116
|
+
} catch {
|
|
2117
|
+
outputEmpty();
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const verdict = parseVerdict(gradeResp);
|
|
2122
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
2123
|
+
|
|
2124
|
+
if (!verdict.ok) {
|
|
2125
|
+
const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
|
|
2126
|
+
const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
|
|
2127
|
+
|
|
2128
|
+
if (mode === 'audit') {
|
|
2129
|
+
const reason = tagStr + ' bashGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
|
|
2130
|
+
outputJson({ systemMessage: reason });
|
|
2131
|
+
dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
|
|
2132
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2133
|
+
command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
|
|
2134
|
+
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
2135
|
+
});
|
|
2136
|
+
} else {
|
|
2137
|
+
const reason = tagStr + ' bashGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
|
|
2138
|
+
outputJson({
|
|
2139
|
+
systemMessage: reason,
|
|
2140
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
|
|
2141
|
+
});
|
|
2142
|
+
dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
2143
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2144
|
+
command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
|
|
2145
|
+
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
} else {
|
|
2149
|
+
outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
|
|
2150
|
+
dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
|
|
2151
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2152
|
+
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
2153
|
+
rulesChecked: config.rules, violatedRules: [],
|
|
2154
|
+
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
2161
|
+
const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
|
|
2162
|
+
|| process.env.SYNKRO_HEADLESS === '1';
|
|
2163
|
+
|
|
2164
|
+
const body: Record<string, any> = {
|
|
2165
|
+
hook_event: 'PreToolUse',
|
|
2166
|
+
tool_name: toolName,
|
|
2167
|
+
tool_input: toolInput,
|
|
2168
|
+
user_intent: transcript.userIntent || null,
|
|
2169
|
+
last_user_message: lastPrompt || null,
|
|
2170
|
+
recent_user_messages: transcript.recentUserMessages,
|
|
2171
|
+
recent_messages: transcript.recentMessages,
|
|
2172
|
+
recent_actions: transcript.recentActions,
|
|
2173
|
+
session_id: sessionId || null,
|
|
2174
|
+
tool_use_id: toolUseId || null,
|
|
2175
|
+
cwd: cwd || null,
|
|
2176
|
+
repo: gitRepo || null,
|
|
2177
|
+
permission_mode: permissionMode || null,
|
|
2178
|
+
headless: isHeadless,
|
|
2179
|
+
cc_model: transcript.ccModel || null,
|
|
2180
|
+
cc_usage: transcript.ccUsage || {},
|
|
2181
|
+
session_summary: transcript.sessionSummary || null,
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
|
|
2185
|
+
|
|
2186
|
+
if (!resp) {
|
|
2187
|
+
log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
|
|
2188
|
+
outputEmpty();
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
if (!resp.hook_response || typeof resp.hook_response !== 'object') {
|
|
2193
|
+
log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
|
|
2194
|
+
outputEmpty();
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
outputJson(resp.hook_response);
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
|
|
2201
|
+
outputEmpty();
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
main();
|
|
2206
|
+
`;
|
|
2207
|
+
PLAN_JUDGE_TS = `#!/usr/bin/env bun
|
|
2208
|
+
import {
|
|
2209
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
2210
|
+
parseVerdict, dispatchCapture, postWithRetry, readStdin, log,
|
|
2211
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2212
|
+
} from './_synkro-common.ts';
|
|
2213
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
2214
|
+
import { join } from 'node:path';
|
|
2215
|
+
import { homedir } from 'node:os';
|
|
2216
|
+
|
|
2217
|
+
function findLatestPlan(): string | null {
|
|
2218
|
+
const plansDir = join(homedir(), '.claude', 'plans');
|
|
2219
|
+
if (!existsSync(plansDir)) return null;
|
|
2220
|
+
try {
|
|
2221
|
+
const files = readdirSync(plansDir)
|
|
2222
|
+
.filter(f => f.endsWith('.md'))
|
|
2223
|
+
.map(f => ({ name: f, mtime: statSync(join(plansDir, f)).mtimeMs }))
|
|
2224
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
2225
|
+
return files.length > 0 ? join(plansDir, files[0].name) : null;
|
|
2226
|
+
} catch {
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function appendReviewToPlan(planFile: string, verdict: string): void {
|
|
2232
|
+
try {
|
|
2233
|
+
let content = readFileSync(planFile, 'utf-8');
|
|
2234
|
+
content = content.replace(/<!-- synkro-plan-review -->[\\s\\S]*?<!-- \\/synkro-plan-review -->/g, '').trimEnd();
|
|
2235
|
+
const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
2236
|
+
content += '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \\u2014 ' + now + '\\n\\n' + verdict + '\\n\\n<!-- /synkro-plan-review -->\\n';
|
|
2237
|
+
writeFileSync(planFile, content, 'utf-8');
|
|
2238
|
+
} catch {}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
async function main() {
|
|
2242
|
+
try {
|
|
2243
|
+
const input = await readStdin();
|
|
2244
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2245
|
+
|
|
2246
|
+
const payload = JSON.parse(input);
|
|
2247
|
+
const toolName = payload.tool_name || '';
|
|
2248
|
+
if (toolName !== 'ExitPlanMode') { outputEmpty(); return; }
|
|
2249
|
+
|
|
2250
|
+
const planFile = findLatestPlan();
|
|
2251
|
+
if (!planFile) { outputEmpty(); return; }
|
|
2252
|
+
const plan = readFileSync(planFile, 'utf-8');
|
|
2253
|
+
if (plan.length < 20) { outputEmpty(); return; }
|
|
2254
|
+
|
|
2255
|
+
const sessionId = payload.session_id || '';
|
|
2256
|
+
const cwd = payload.cwd || '';
|
|
2257
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2258
|
+
|
|
2259
|
+
const planShort = plan.slice(0, 80);
|
|
2260
|
+
log('planReview checking: ' + planShort + '...');
|
|
2261
|
+
|
|
2262
|
+
let jwt = loadJwt();
|
|
2263
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2264
|
+
jwt = await ensureFreshJwt(jwt);
|
|
2265
|
+
|
|
2266
|
+
const config = await loadConfig(jwt);
|
|
2267
|
+
const rt = await route(config);
|
|
2268
|
+
const tagStr = tag(rt, config);
|
|
2269
|
+
|
|
2270
|
+
if (config.silent) {
|
|
2271
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (rt === 'local') {
|
|
2276
|
+
const graderPrompt = [
|
|
2277
|
+
'Working directory: ' + (cwd || '.'),
|
|
2278
|
+
'Repo: ' + (gitRepo || 'unknown'),
|
|
2279
|
+
'Plan:',
|
|
2280
|
+
plan.slice(0, 8000),
|
|
2281
|
+
'Org rules: ' + JSON.stringify(config.rules),
|
|
2282
|
+
].join('\\n');
|
|
2283
|
+
|
|
2284
|
+
let gradeResp: string;
|
|
2285
|
+
try {
|
|
2286
|
+
gradeResp = await localGrade('plan', graderPrompt);
|
|
2287
|
+
} catch {
|
|
2288
|
+
outputEmpty();
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const verdict = parseVerdict(gradeResp);
|
|
2293
|
+
const planContent = plan.slice(0, 2000);
|
|
2294
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
2295
|
+
|
|
2296
|
+
if (!verdict.ok) {
|
|
2297
|
+
const reviewMsg = (verdict.ruleId ? '(first: ' + verdict.ruleId + ') ' : '') + (verdict.reason || 'check org rules during implementation');
|
|
2298
|
+
appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reviewMsg);
|
|
2299
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 ' + reviewMsg });
|
|
2300
|
+
dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
|
|
2301
|
+
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
2302
|
+
command: planContent, reasoning: verdict.reason || 'check org rules',
|
|
2303
|
+
rulesChecked: config.rules, violatedRules,
|
|
2304
|
+
});
|
|
2305
|
+
} else {
|
|
2306
|
+
const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
|
|
2307
|
+
appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
|
|
2308
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 clean: ' + reviewMsg });
|
|
2309
|
+
dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
|
|
2310
|
+
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
2311
|
+
command: planContent, reasoning: reviewMsg,
|
|
2312
|
+
rulesChecked: config.rules, violatedRules: [],
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
2319
|
+
const body = {
|
|
2320
|
+
hook_event: 'PreToolUse',
|
|
2321
|
+
tool_name: 'ExitPlanMode',
|
|
2322
|
+
tool_input: { plan: plan.slice(0, 16000) },
|
|
2323
|
+
session_id: sessionId || null,
|
|
2324
|
+
cwd: cwd || null,
|
|
2325
|
+
repo: gitRepo || null,
|
|
2326
|
+
};
|
|
2327
|
+
|
|
2328
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
|
|
2329
|
+
|
|
2330
|
+
if (!resp) {
|
|
2331
|
+
log('planReview \\u2192 error (timeout)');
|
|
2332
|
+
outputEmpty();
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
const hookResp = resp?.hook_response;
|
|
2337
|
+
if (!hookResp) { outputEmpty(); return; }
|
|
2338
|
+
|
|
2339
|
+
const decision = hookResp?.hookSpecificOutput?.permissionDecision;
|
|
2340
|
+
if (decision) {
|
|
2341
|
+
const reason = hookResp?.hookSpecificOutput?.permissionDecisionReason || 'check org rules';
|
|
2342
|
+
appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reason);
|
|
2343
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 advisory: ' + reason });
|
|
2344
|
+
} else {
|
|
2345
|
+
const cloudMsg = hookResp.systemMessage || '';
|
|
2346
|
+
if (cloudMsg) appendReviewToPlan(planFile, '\\u2705 ' + cloudMsg);
|
|
2347
|
+
outputJson(hookResp);
|
|
2348
|
+
}
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
process.stderr.write('[synkro] planReview error: ' + String(err) + '\\n');
|
|
2351
|
+
outputEmpty();
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
1872
2354
|
|
|
1873
|
-
|
|
1874
|
-
|
|
2355
|
+
main();
|
|
2356
|
+
`;
|
|
2357
|
+
STOP_SUMMARY_TS = `#!/usr/bin/env bun
|
|
2358
|
+
import {
|
|
2359
|
+
loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
|
|
2360
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2361
|
+
} from './_synkro-common.ts';
|
|
1875
2362
|
|
|
1876
|
-
(
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2363
|
+
async function main() {
|
|
2364
|
+
try {
|
|
2365
|
+
const input = await readStdin();
|
|
2366
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2367
|
+
|
|
2368
|
+
const payload = JSON.parse(input);
|
|
2369
|
+
const sessionId = payload.session_id || '';
|
|
2370
|
+
if (!sessionId) { outputEmpty(); return; }
|
|
2371
|
+
|
|
2372
|
+
const cwd = payload.cwd || '';
|
|
2373
|
+
const transcriptPath = payload.transcript_path || '';
|
|
2374
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2375
|
+
|
|
2376
|
+
let jwt = loadJwt();
|
|
2377
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2378
|
+
|
|
2379
|
+
if (transcriptPath) {
|
|
2380
|
+
const usage = aggregateUsage(transcriptPath);
|
|
2381
|
+
if (usage.totals.in + usage.totals.out > 0) {
|
|
2382
|
+
const usageBody = {
|
|
2383
|
+
capture_type: 'local_verdict',
|
|
2384
|
+
event_id: 'usage_' + Date.now() + '_' + process.pid,
|
|
2385
|
+
hook_type: 'stop',
|
|
2386
|
+
verdict: 'allow',
|
|
2387
|
+
severity: 'none',
|
|
2388
|
+
model: usage.model || 'unknown',
|
|
2389
|
+
cc_model: usage.model || '',
|
|
2390
|
+
cc_usage: {
|
|
2391
|
+
input_tokens: usage.totals.in,
|
|
2392
|
+
output_tokens: usage.totals.out,
|
|
2393
|
+
cache_creation_input_tokens: usage.totals.cw,
|
|
2394
|
+
cache_read_input_tokens: usage.totals.cr,
|
|
2395
|
+
},
|
|
2396
|
+
...(gitRepo ? { repo: gitRepo } : {}),
|
|
2397
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
2398
|
+
};
|
|
2399
|
+
fetch(GATEWAY_URL + '/api/v1/hook/capture', {
|
|
2400
|
+
method: 'POST',
|
|
2401
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
2402
|
+
body: JSON.stringify(usageBody),
|
|
2403
|
+
signal: AbortSignal.timeout(3000),
|
|
2404
|
+
}).catch(() => {});
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
let resp: any;
|
|
2409
|
+
try {
|
|
2410
|
+
const r = await fetch(GATEWAY_URL + '/api/v1/cli/session-summary?session_id=' + encodeURIComponent(sessionId), {
|
|
2411
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
2412
|
+
signal: AbortSignal.timeout(3000),
|
|
2413
|
+
});
|
|
2414
|
+
resp = await r.json();
|
|
2415
|
+
} catch {
|
|
2416
|
+
outputEmpty();
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const edits = resp?.edits_scanned || 0;
|
|
2421
|
+
const findings = resp?.findings || 0;
|
|
2422
|
+
const autoFixed = resp?.auto_fixed || 0;
|
|
2423
|
+
const open = resp?.open || 0;
|
|
2424
|
+
|
|
2425
|
+
if (!edits) { outputEmpty(); return; }
|
|
2426
|
+
|
|
2427
|
+
const config = await loadConfig(jwt);
|
|
2428
|
+
const tagStr = tag('local', config);
|
|
1882
2429
|
|
|
1883
|
-
|
|
1884
|
-
|
|
2430
|
+
if (!findings) {
|
|
2431
|
+
outputJson({ systemMessage: tagStr + ' stop \\u2192 0 issues across ' + edits + ' edit(s), session complete' });
|
|
2432
|
+
} else {
|
|
2433
|
+
outputJson({ systemMessage: tagStr + ' stop \\u2192 ' + findings + ' finding(s): ' + autoFixed + ' auto-fixed, ' + open + ' open' });
|
|
2434
|
+
}
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
process.stderr.write('[synkro] stopSummary error: ' + String(err) + '\\n');
|
|
2437
|
+
outputEmpty();
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
main();
|
|
1885
2442
|
`;
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2443
|
+
SESSION_START_TS = `#!/usr/bin/env bun
|
|
2444
|
+
import {
|
|
2445
|
+
loadJwt, detectRepo, channelUp, tag, readStdin,
|
|
2446
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2447
|
+
type HookConfig,
|
|
2448
|
+
} from './_synkro-common.ts';
|
|
1889
2449
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2450
|
+
async function main() {
|
|
2451
|
+
try {
|
|
2452
|
+
const input = await readStdin();
|
|
2453
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1893
2454
|
|
|
1894
|
-
|
|
1895
|
-
|
|
2455
|
+
const payload = JSON.parse(input);
|
|
2456
|
+
const cwd = payload.cwd || '';
|
|
2457
|
+
const sessionId = payload.session_id || '';
|
|
2458
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1896
2459
|
|
|
1897
|
-
|
|
1898
|
-
if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
|
|
2460
|
+
let jwt = loadJwt();
|
|
1899
2461
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
2462
|
+
const isChannelUp = await channelUp();
|
|
2463
|
+
const rt = isChannelUp ? 'local' : 'cloud';
|
|
1903
2464
|
|
|
1904
|
-
|
|
1905
|
-
|
|
2465
|
+
let policyName = '';
|
|
2466
|
+
let silent = false;
|
|
2467
|
+
let openFindings = 0;
|
|
1906
2468
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2469
|
+
if (jwt) {
|
|
2470
|
+
try {
|
|
2471
|
+
const url = GATEWAY_URL + '/api/v1/hook/config?session_id=' + encodeURIComponent(sessionId || '') + '&repo=' + encodeURIComponent(gitRepo || '');
|
|
2472
|
+
const r = await fetch(url, {
|
|
2473
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
2474
|
+
signal: AbortSignal.timeout(3000),
|
|
2475
|
+
});
|
|
2476
|
+
const data = await r.json() as any;
|
|
2477
|
+
silent = data.silent_mode === true || data.silent_mode === 'true';
|
|
2478
|
+
policyName = data.active_policy_name || '';
|
|
2479
|
+
openFindings = data.session_context?.open_findings || 0;
|
|
2480
|
+
} catch {}
|
|
2481
|
+
}
|
|
1911
2482
|
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
--arg cwd "$CWD" \\
|
|
1916
|
-
--arg repo "$GIT_REPO" \\
|
|
1917
|
-
'{
|
|
1918
|
-
hook_event: "PreToolUse",
|
|
1919
|
-
tool_name: "Bash",
|
|
1920
|
-
tool_input: {command: $cmd},
|
|
1921
|
-
response_format: "cursor",
|
|
1922
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1923
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1924
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1925
|
-
}')
|
|
2483
|
+
const fakeConfig: HookConfig = { captureDepth: 'local_only', tier: 'standard', silent, policyName, rules: [] };
|
|
2484
|
+
const tagStr = tag(rt, fakeConfig);
|
|
2485
|
+
const routeLine = tagStr + ' inference: ' + (isChannelUp ? 'local-cc (channel reachable on 127.0.0.1:8929)' : 'cloud (local-cc channel not reachable)');
|
|
1926
2486
|
|
|
1927
|
-
|
|
2487
|
+
if (!jwt) {
|
|
2488
|
+
outputJson({ systemMessage: routeLine });
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
1928
2491
|
|
|
1929
|
-
if
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
2492
|
+
if (!openFindings) {
|
|
2493
|
+
outputJson({ systemMessage: routeLine });
|
|
2494
|
+
} else if (openFindings === 1) {
|
|
2495
|
+
outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 1 open finding in this repo from a prior session.' });
|
|
2496
|
+
} else {
|
|
2497
|
+
outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 ' + openFindings + ' open findings in this repo from prior sessions.' });
|
|
2498
|
+
}
|
|
2499
|
+
} catch (err) {
|
|
2500
|
+
process.stderr.write('[synkro] sessionStart error: ' + String(err) + '\\n');
|
|
2501
|
+
outputEmpty();
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
1933
2504
|
|
|
1934
|
-
|
|
1935
|
-
if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
|
|
1936
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
1937
|
-
else
|
|
1938
|
-
echo '{}'
|
|
1939
|
-
fi
|
|
1940
|
-
exit 0
|
|
2505
|
+
main();
|
|
1941
2506
|
`;
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2507
|
+
BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
|
|
2508
|
+
import {
|
|
2509
|
+
loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
|
|
2510
|
+
outputEmpty, GATEWAY_URL,
|
|
2511
|
+
} from './_synkro-common.ts';
|
|
1945
2512
|
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2513
|
+
async function main() {
|
|
2514
|
+
try {
|
|
2515
|
+
const input = await readStdin();
|
|
2516
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1949
2517
|
|
|
1950
|
-
|
|
1951
|
-
|
|
2518
|
+
const payload = JSON.parse(input);
|
|
2519
|
+
const toolName = payload.tool_name || '';
|
|
2520
|
+
if (toolName !== 'Bash') { outputEmpty(); return; }
|
|
1952
2521
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
1956
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
2522
|
+
const jwt = loadJwt();
|
|
2523
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1957
2524
|
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
if
|
|
2525
|
+
const sessionId = payload.session_id || '';
|
|
2526
|
+
const toolUseId = payload.tool_use_id || '';
|
|
2527
|
+
if (!sessionId || !toolUseId) { outputEmpty(); return; }
|
|
1961
2528
|
|
|
1962
|
-
|
|
1963
|
-
|
|
2529
|
+
const isError = payload.tool_result?.is_error === true;
|
|
2530
|
+
const cmd = payload.tool_input?.command || '';
|
|
2531
|
+
const cmdHash = cmd ? hashCommand(cmd) : '';
|
|
1964
2532
|
|
|
1965
|
-
|
|
1966
|
-
if
|
|
1967
|
-
|
|
1968
|
-
|
|
2533
|
+
if (cmdHash && sessionId) {
|
|
2534
|
+
if (!isError) {
|
|
2535
|
+
consentConsume(sessionId, cmdHash);
|
|
2536
|
+
} else {
|
|
2537
|
+
if (!consentHasActive(sessionId, cmdHash)) {
|
|
2538
|
+
consentGrant(sessionId, cmdHash);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
1969
2542
|
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
hook_event: "PreToolUse",
|
|
1978
|
-
tool_name: "Edit",
|
|
1979
|
-
tool_input: {file_path: $file_path, content: $content},
|
|
1980
|
-
file_path: $file_path,
|
|
1981
|
-
content: $content,
|
|
1982
|
-
response_format: "cursor",
|
|
1983
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1984
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1985
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1986
|
-
}')
|
|
2543
|
+
const body = {
|
|
2544
|
+
capture_type: 'bash_followup',
|
|
2545
|
+
session_id: sessionId,
|
|
2546
|
+
tool_use_id: toolUseId,
|
|
2547
|
+
is_error: isError,
|
|
2548
|
+
command_hash: cmdHash,
|
|
2549
|
+
};
|
|
1987
2550
|
|
|
1988
|
-
|
|
2551
|
+
fetch(GATEWAY_URL + '/api/v1/hook/capture', {
|
|
2552
|
+
method: 'POST',
|
|
2553
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
2554
|
+
body: JSON.stringify(body),
|
|
2555
|
+
signal: AbortSignal.timeout(3000),
|
|
2556
|
+
}).catch(() => {});
|
|
1989
2557
|
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2558
|
+
outputEmpty();
|
|
2559
|
+
} catch {
|
|
2560
|
+
outputEmpty();
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
1994
2563
|
|
|
1995
|
-
|
|
1996
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
1997
|
-
else
|
|
1998
|
-
echo '{}'
|
|
1999
|
-
fi
|
|
2000
|
-
exit 0
|
|
2564
|
+
main();
|
|
2001
2565
|
`;
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2566
|
+
TRANSCRIPT_SYNC_TS = `#!/usr/bin/env bun
|
|
2567
|
+
import {
|
|
2568
|
+
loadJwt, detectRepo, readStdin, aggregateUsage,
|
|
2569
|
+
outputEmpty, GATEWAY_URL,
|
|
2570
|
+
} from './_synkro-common.ts';
|
|
2571
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2572
|
+
import { join, dirname } from 'node:path';
|
|
2573
|
+
import { homedir } from 'node:os';
|
|
2005
2574
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2575
|
+
async function main() {
|
|
2576
|
+
try {
|
|
2577
|
+
const input = await readStdin();
|
|
2578
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2008
2579
|
|
|
2009
|
-
|
|
2010
|
-
|
|
2580
|
+
const payload = JSON.parse(input);
|
|
2581
|
+
const sessionId = payload.session_id || '';
|
|
2582
|
+
const transcriptPath = payload.transcript_path || '';
|
|
2583
|
+
const cwd = payload.cwd || '';
|
|
2011
2584
|
|
|
2012
|
-
|
|
2013
|
-
|
|
2585
|
+
if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
|
|
2586
|
+
outputEmpty();
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2014
2589
|
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2590
|
+
const jwt = loadJwt();
|
|
2591
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2592
|
+
|
|
2593
|
+
const usage = aggregateUsage(transcriptPath);
|
|
2594
|
+
if (usage.totals.in + usage.totals.out > 0) {
|
|
2595
|
+
const usageBody = {
|
|
2596
|
+
capture_type: 'usage_tick',
|
|
2597
|
+
event_id: 'usage_' + Date.now() + '_' + process.pid,
|
|
2598
|
+
hook_type: 'stop',
|
|
2599
|
+
verdict: 'allow',
|
|
2600
|
+
severity: 'none',
|
|
2601
|
+
model: usage.model || 'unknown',
|
|
2602
|
+
cc_model: usage.model || '',
|
|
2603
|
+
cc_usage: {
|
|
2604
|
+
input_tokens: usage.totals.in,
|
|
2605
|
+
output_tokens: usage.totals.out,
|
|
2606
|
+
cache_creation_input_tokens: usage.totals.cw,
|
|
2607
|
+
cache_read_input_tokens: usage.totals.cr,
|
|
2608
|
+
},
|
|
2609
|
+
session_id: sessionId,
|
|
2610
|
+
};
|
|
2611
|
+
fetch(GATEWAY_URL + '/api/v1/hook/capture', {
|
|
2612
|
+
method: 'POST',
|
|
2613
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
2614
|
+
body: JSON.stringify(usageBody),
|
|
2615
|
+
signal: AbortSignal.timeout(3000),
|
|
2616
|
+
}).catch(() => {});
|
|
2617
|
+
}
|
|
2019
2618
|
|
|
2020
|
-
|
|
2021
|
-
[ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
|
|
2022
|
-
FULL_CONTENT=""
|
|
2023
|
-
[ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
|
|
2619
|
+
if (process.env.SYNKRO_TRANSCRIPT_CONSENT === 'no') { outputEmpty(); return; }
|
|
2024
2620
|
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
while [ "$_PKG_DIR" != "/" ]; do
|
|
2028
|
-
if [ -f "$_PKG_DIR/package.json" ]; then
|
|
2029
|
-
DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
|
|
2030
|
-
break
|
|
2031
|
-
fi
|
|
2032
|
-
_PKG_DIR=$(dirname "$_PKG_DIR")
|
|
2033
|
-
done
|
|
2621
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2622
|
+
if (!gitRepo) { outputEmpty(); return; }
|
|
2034
2623
|
|
|
2035
|
-
|
|
2624
|
+
let captureDepth = 'local_only';
|
|
2625
|
+
try {
|
|
2626
|
+
const r = await fetch(GATEWAY_URL + '/api/v1/hook/config', {
|
|
2627
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
2628
|
+
signal: AbortSignal.timeout(3000),
|
|
2629
|
+
});
|
|
2630
|
+
const data = await r.json() as any;
|
|
2631
|
+
captureDepth = data.capture_depth || 'local_only';
|
|
2632
|
+
} catch {}
|
|
2633
|
+
|
|
2634
|
+
if (captureDepth === 'local_only') { outputEmpty(); return; }
|
|
2635
|
+
|
|
2636
|
+
const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
|
|
2637
|
+
mkdirSync(offsetDir, { recursive: true });
|
|
2638
|
+
const offsetFile = join(offsetDir, sessionId);
|
|
2639
|
+
let offset = 0;
|
|
2640
|
+
if (existsSync(offsetFile)) {
|
|
2641
|
+
try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
|
|
2642
|
+
}
|
|
2036
2643
|
|
|
2037
|
-
(
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
--arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
|
|
2041
|
-
--argjson deps "$DEPS_JSON" \\
|
|
2042
|
-
'{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
|
|
2043
|
-
+ (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
|
|
2044
|
-
+ (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
|
|
2045
|
-
+ (if ($repo | length) > 0 then {repo:$repo} else {} end)')
|
|
2046
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
2047
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
2048
|
-
-d "$BODY" --max-time 10 >/dev/null 2>&1 || true
|
|
2049
|
-
) &
|
|
2050
|
-
disown 2>/dev/null || true
|
|
2644
|
+
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
2645
|
+
const allLines = raw.split('\\n').filter(l => l.trim());
|
|
2646
|
+
const totalLines = allLines.length;
|
|
2051
2647
|
|
|
2052
|
-
|
|
2053
|
-
exit 0
|
|
2054
|
-
`;
|
|
2055
|
-
CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
|
|
2056
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
2057
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
2648
|
+
if (totalLines <= offset) { outputEmpty(); return; }
|
|
2058
2649
|
|
|
2059
|
-
|
|
2060
|
-
|
|
2650
|
+
let startIdx = offset;
|
|
2651
|
+
const delta = totalLines - offset;
|
|
2652
|
+
if (delta > 200) startIdx = totalLines - 200;
|
|
2061
2653
|
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2654
|
+
const messages: any[] = [];
|
|
2655
|
+
for (let i = startIdx; i < totalLines; i++) {
|
|
2656
|
+
try {
|
|
2657
|
+
const entry = JSON.parse(allLines[i]);
|
|
2658
|
+
if (entry.type !== 'user' && entry.type !== 'assistant') continue;
|
|
2659
|
+
const content = entry.message?.content;
|
|
2660
|
+
let text = '';
|
|
2661
|
+
if (typeof content === 'string') text = content.slice(0, 8000);
|
|
2662
|
+
else if (Array.isArray(content)) {
|
|
2663
|
+
text = content.map((c: any) => {
|
|
2664
|
+
if (typeof c === 'string') return c;
|
|
2665
|
+
if (c?.type === 'text') return c.text || '';
|
|
2666
|
+
return '';
|
|
2667
|
+
}).join(' ').slice(0, 8000);
|
|
2668
|
+
}
|
|
2065
2669
|
|
|
2066
|
-
|
|
2067
|
-
|
|
2670
|
+
const msg: any = { message_index: i, type: entry.type, content: text };
|
|
2671
|
+
if (entry.type === 'assistant') {
|
|
2672
|
+
const toolCalls = (Array.isArray(content) ? content : [])
|
|
2673
|
+
.filter((c: any) => c?.type === 'tool_use')
|
|
2674
|
+
.map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
|
|
2675
|
+
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
2676
|
+
msg.model = entry.message?.model || null;
|
|
2677
|
+
const u = entry.message?.usage;
|
|
2678
|
+
if (u) msg.usage = { input_tokens: u.input_tokens, output_tokens: u.output_tokens, cache_creation_input_tokens: u.cache_creation_input_tokens, cache_read_input_tokens: u.cache_read_input_tokens };
|
|
2679
|
+
}
|
|
2680
|
+
messages.push(msg);
|
|
2681
|
+
} catch {}
|
|
2682
|
+
}
|
|
2068
2683
|
|
|
2069
|
-
|
|
2070
|
-
CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
2071
|
-
CMD_HASH=""
|
|
2072
|
-
if [ -n "$CMD" ]; then
|
|
2073
|
-
CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
|
|
2074
|
-
fi
|
|
2684
|
+
writeFileSync(offsetFile, String(totalLines), 'utf-8');
|
|
2075
2685
|
|
|
2076
|
-
if
|
|
2077
|
-
if [ "$IS_ERROR" = "false" ]; then
|
|
2078
|
-
synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
|
|
2079
|
-
else
|
|
2080
|
-
if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
|
|
2081
|
-
synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
|
|
2082
|
-
fi
|
|
2083
|
-
fi
|
|
2084
|
-
fi
|
|
2686
|
+
if (messages.length === 0) { outputEmpty(); return; }
|
|
2085
2687
|
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2688
|
+
const syncBody = {
|
|
2689
|
+
repo: gitRepo,
|
|
2690
|
+
sessions: [{ cc_session_id: sessionId, messages }],
|
|
2691
|
+
};
|
|
2692
|
+
fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
|
|
2693
|
+
method: 'POST',
|
|
2694
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
2695
|
+
body: JSON.stringify(syncBody),
|
|
2696
|
+
signal: AbortSignal.timeout(10000),
|
|
2697
|
+
}).catch(() => {});
|
|
2698
|
+
|
|
2699
|
+
outputEmpty();
|
|
2700
|
+
} catch {
|
|
2701
|
+
outputEmpty();
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2097
2704
|
|
|
2098
|
-
|
|
2099
|
-
exit 0
|
|
2705
|
+
main();
|
|
2100
2706
|
`;
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2707
|
+
USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
|
|
2708
|
+
import { readStdin } from './_synkro-common.ts';
|
|
2709
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2710
|
+
import { join, dirname } from 'node:path';
|
|
2711
|
+
import { homedir } from 'node:os';
|
|
2712
|
+
|
|
2713
|
+
async function main() {
|
|
2714
|
+
try {
|
|
2715
|
+
const input = await readStdin();
|
|
2716
|
+
if (!input.trim()) return;
|
|
2717
|
+
const payload = JSON.parse(input);
|
|
2718
|
+
const msg = payload.message || payload.prompt || payload.content || '';
|
|
2719
|
+
if (msg) {
|
|
2720
|
+
const promptFile = join(homedir(), '.synkro', '.last-prompt');
|
|
2721
|
+
mkdirSync(dirname(promptFile), { recursive: true });
|
|
2722
|
+
writeFileSync(promptFile, msg, 'utf-8');
|
|
2723
|
+
}
|
|
2724
|
+
} catch {}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
main();
|
|
2113
2728
|
`;
|
|
2114
2729
|
}
|
|
2115
2730
|
});
|
|
@@ -4322,34 +4937,36 @@ function ensureSynkroDir() {
|
|
|
4322
4937
|
mkdirSync8(OFFSETS_DIR, { recursive: true });
|
|
4323
4938
|
}
|
|
4324
4939
|
function writeHookScripts() {
|
|
4325
|
-
const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.
|
|
4326
|
-
const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.
|
|
4940
|
+
const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
|
|
4941
|
+
const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
|
|
4327
4942
|
const editCaptureScriptPath = join11(HOOKS_DIR, "cc-edit-capture.sh");
|
|
4328
|
-
const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.
|
|
4329
|
-
const
|
|
4330
|
-
const
|
|
4331
|
-
const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.
|
|
4332
|
-
const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.
|
|
4333
|
-
const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.
|
|
4334
|
-
const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.
|
|
4335
|
-
const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.
|
|
4336
|
-
const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.
|
|
4943
|
+
const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
|
|
4944
|
+
const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
|
|
4945
|
+
const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
|
|
4946
|
+
const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
|
|
4947
|
+
const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
|
|
4948
|
+
const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
|
|
4949
|
+
const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
|
|
4950
|
+
const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
|
|
4951
|
+
const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
|
|
4952
|
+
const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
|
|
4337
4953
|
const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
|
|
4338
4954
|
const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
|
|
4339
4955
|
const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
|
|
4340
4956
|
const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
|
|
4341
|
-
writeFileSync7(bashScriptPath,
|
|
4342
|
-
writeFileSync7(bashFollowupScriptPath,
|
|
4343
|
-
writeFileSync7(editCaptureScriptPath,
|
|
4344
|
-
writeFileSync7(editPrecheckScriptPath,
|
|
4345
|
-
writeFileSync7(
|
|
4346
|
-
writeFileSync7(
|
|
4347
|
-
writeFileSync7(planJudgeScriptPath,
|
|
4348
|
-
writeFileSync7(stopSummaryScriptPath,
|
|
4349
|
-
writeFileSync7(sessionStartScriptPath,
|
|
4350
|
-
writeFileSync7(transcriptSyncScriptPath,
|
|
4351
|
-
writeFileSync7(userPromptSubmitScriptPath,
|
|
4352
|
-
writeFileSync7(commonScriptPath,
|
|
4957
|
+
writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
4958
|
+
writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
4959
|
+
writeFileSync7(editCaptureScriptPath, "", "utf-8");
|
|
4960
|
+
writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
|
|
4961
|
+
writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
|
|
4962
|
+
writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
|
|
4963
|
+
writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
|
|
4964
|
+
writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
|
|
4965
|
+
writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
|
|
4966
|
+
writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
|
|
4967
|
+
writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
|
|
4968
|
+
writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
|
|
4969
|
+
writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
|
|
4353
4970
|
writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
|
|
4354
4971
|
writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
|
|
4355
4972
|
writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
|
|
@@ -4358,14 +4975,15 @@ function writeHookScripts() {
|
|
|
4358
4975
|
chmodSync2(bashFollowupScriptPath, 493);
|
|
4359
4976
|
chmodSync2(editCaptureScriptPath, 493);
|
|
4360
4977
|
chmodSync2(editPrecheckScriptPath, 493);
|
|
4361
|
-
chmodSync2(
|
|
4362
|
-
chmodSync2(
|
|
4978
|
+
chmodSync2(cwePrecheckScriptPath, 493);
|
|
4979
|
+
chmodSync2(cvePrecheckScriptPath, 493);
|
|
4363
4980
|
chmodSync2(planJudgeScriptPath, 493);
|
|
4364
4981
|
chmodSync2(stopSummaryScriptPath, 493);
|
|
4365
4982
|
chmodSync2(sessionStartScriptPath, 493);
|
|
4366
4983
|
chmodSync2(transcriptSyncScriptPath, 493);
|
|
4367
4984
|
chmodSync2(userPromptSubmitScriptPath, 493);
|
|
4368
4985
|
chmodSync2(commonScriptPath, 493);
|
|
4986
|
+
chmodSync2(commonBashScriptPath, 493);
|
|
4369
4987
|
chmodSync2(cursorBashJudgePath, 493);
|
|
4370
4988
|
chmodSync2(cursorEditPrecheckPath, 493);
|
|
4371
4989
|
chmodSync2(cursorEditCapturePath, 493);
|
|
@@ -4375,8 +4993,8 @@ function writeHookScripts() {
|
|
|
4375
4993
|
bashFollowupScript: bashFollowupScriptPath,
|
|
4376
4994
|
editCaptureScript: editCaptureScriptPath,
|
|
4377
4995
|
editPrecheckScript: editPrecheckScriptPath,
|
|
4378
|
-
|
|
4379
|
-
|
|
4996
|
+
cwePrecheckScript: cwePrecheckScriptPath,
|
|
4997
|
+
cvePrecheckScript: cvePrecheckScriptPath,
|
|
4380
4998
|
planJudgeScript: planJudgeScriptPath,
|
|
4381
4999
|
stopSummaryScript: stopSummaryScriptPath,
|
|
4382
5000
|
sessionStartScript: sessionStartScriptPath,
|
|
@@ -4417,7 +5035,7 @@ function writeConfigEnv(opts) {
|
|
|
4417
5035
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
4418
5036
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
4419
5037
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
4420
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.4.
|
|
5038
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.4.47")}`
|
|
4421
5039
|
];
|
|
4422
5040
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
4423
5041
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -4725,8 +5343,8 @@ async function installCommand(opts = {}) {
|
|
|
4725
5343
|
bashFollowupScriptPath: scripts.bashFollowupScript,
|
|
4726
5344
|
editCaptureScriptPath: scripts.editCaptureScript,
|
|
4727
5345
|
editPrecheckScriptPath: scripts.editPrecheckScript,
|
|
4728
|
-
|
|
4729
|
-
|
|
5346
|
+
cwePrecheckScriptPath: scripts.cwePrecheckScript,
|
|
5347
|
+
cvePrecheckScriptPath: scripts.cvePrecheckScript,
|
|
4730
5348
|
planJudgeScriptPath: scripts.planJudgeScript,
|
|
4731
5349
|
stopSummaryScriptPath: scripts.stopSummaryScript,
|
|
4732
5350
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
@@ -5131,6 +5749,7 @@ var init_install2 = __esm({
|
|
|
5131
5749
|
init_cursorHookConfig();
|
|
5132
5750
|
init_mcpConfig();
|
|
5133
5751
|
init_hookScripts();
|
|
5752
|
+
init_hookScriptsTs();
|
|
5134
5753
|
init_stub();
|
|
5135
5754
|
init_repoConnect();
|
|
5136
5755
|
init_projects();
|