@synkro-sh/cli 1.4.45 → 1.4.48
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 +2043 -1404
- 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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
}
|
|
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
|
|
834
|
+
fi
|
|
835
|
+
|
|
836
|
+
echo '{}'
|
|
837
|
+
exit 0
|
|
838
|
+
`;
|
|
839
|
+
}
|
|
840
|
+
});
|
|
1397
841
|
|
|
1398
|
-
|
|
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';
|
|
1399
853
|
|
|
1400
|
-
|
|
1401
|
-
synkro_log "planReview \u2192 error (timeout)"
|
|
1402
|
-
echo '{}'
|
|
1403
|
-
exit 0
|
|
1404
|
-
fi
|
|
854
|
+
// \u2500\u2500\u2500 Config \u2500\u2500\u2500
|
|
1405
855
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
exit 0
|
|
1409
|
-
fi
|
|
856
|
+
const HOME = homedir();
|
|
857
|
+
const CONFIG_PATH = join(HOME, '.synkro', 'config.env');
|
|
1410
858
|
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
+
}
|
|
878
|
+
|
|
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');
|
|
882
|
+
|
|
883
|
+
// \u2500\u2500\u2500 Logging \u2500\u2500\u2500
|
|
884
|
+
|
|
885
|
+
export function log(msg: string): void {
|
|
886
|
+
process.stderr.write('[synkro] ' + msg + '\\n');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
|
|
890
|
+
|
|
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
|
+
}
|
|
900
|
+
|
|
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
|
+
}
|
|
914
|
+
|
|
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;
|
|
920
|
+
|
|
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
|
+
}
|
|
944
|
+
|
|
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;
|
|
950
|
+
|
|
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
|
+
}
|
|
966
|
+
|
|
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
|
+
}
|
|
981
|
+
|
|
982
|
+
// \u2500\u2500\u2500 Repo Detection \u2500\u2500\u2500
|
|
983
|
+
|
|
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
|
+
}
|
|
996
|
+
|
|
997
|
+
// \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
|
|
998
|
+
|
|
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
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export async function cweChannelUp(): Promise<boolean> {
|
|
1014
|
+
return channelUp(8930);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
|
|
1018
|
+
|
|
1019
|
+
export interface Rule {
|
|
1020
|
+
rule_id: string;
|
|
1021
|
+
text: string;
|
|
1022
|
+
severity: string;
|
|
1023
|
+
category: string;
|
|
1024
|
+
mode: string;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export interface HookConfig {
|
|
1028
|
+
captureDepth: string;
|
|
1029
|
+
tier: string;
|
|
1030
|
+
silent: boolean;
|
|
1031
|
+
policyName: string;
|
|
1032
|
+
rules: Rule[];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
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
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// \u2500\u2500\u2500 Routing \u2500\u2500\u2500
|
|
1070
|
+
|
|
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
|
+
}
|
|
1076
|
+
|
|
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
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
|
|
1084
|
+
|
|
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
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
|
|
1092
|
+
|
|
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[];
|
|
1098
|
+
|
|
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
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const child = spawn(cmd, args, {
|
|
1109
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1110
|
+
env: { ...process.env, ...envOverride },
|
|
1111
|
+
});
|
|
1112
|
+
|
|
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
|
+
}
|
|
1133
|
+
|
|
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
|
+
}
|
|
1138
|
+
|
|
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
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
|
|
1145
|
+
|
|
1146
|
+
export interface Verdict {
|
|
1147
|
+
ok: boolean;
|
|
1148
|
+
reason: string;
|
|
1149
|
+
ruleId: string;
|
|
1150
|
+
ruleMode: string;
|
|
1151
|
+
severity: string;
|
|
1152
|
+
category: string;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
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
|
+
};
|
|
1164
|
+
|
|
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];
|
|
1170
|
+
|
|
1171
|
+
const okMatch = inner.match(/<ok>(.*?)<\\/ok>/);
|
|
1172
|
+
if (okMatch) verdict.ok = okMatch[1].trim() !== 'false';
|
|
1173
|
+
|
|
1174
|
+
const reasonMatch = inner.match(/<reason>(.*?)<\\/reason>/) || inner.match(/<reasoning>(.*?)<\\/reasoning>/);
|
|
1175
|
+
if (reasonMatch) verdict.reason = reasonMatch[1].trim();
|
|
1176
|
+
|
|
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
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (ruleModeMatch) verdict.ruleMode = ruleModeMatch[1].trim();
|
|
1203
|
+
if (sevMatch) verdict.severity = sevMatch[1].trim();
|
|
1204
|
+
verdict.severity = verdict.severity || 'high';
|
|
1205
|
+
|
|
1206
|
+
const catMatch = inner.match(/<category>(.*?)<\\/category>/);
|
|
1207
|
+
verdict.category = catMatch ? catMatch[1].trim() : 'uncategorized';
|
|
1208
|
+
|
|
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
|
+
}
|
|
1215
|
+
|
|
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;
|
|
1260
|
+
|
|
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
|
+
}
|
|
1269
|
+
|
|
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(() => {});
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
|
|
1279
|
+
|
|
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
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
|
|
1288
|
+
|
|
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
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// \u2500\u2500\u2500 HTTP with Retry \u2500\u2500\u2500
|
|
1333
|
+
|
|
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
|
+
}
|
|
1347
|
+
|
|
1348
|
+
let data: any;
|
|
1349
|
+
try { data = await resp.json(); } catch { return null; }
|
|
1350
|
+
|
|
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
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return data;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// \u2500\u2500\u2500 Read Stdin \u2500\u2500\u2500
|
|
1371
|
+
|
|
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
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
|
|
1381
|
+
|
|
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
|
+
}
|
|
1391
|
+
|
|
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
|
+
};
|
|
1402
|
+
|
|
1403
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return ctx;
|
|
1404
|
+
|
|
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
|
+
}
|
|
1415
|
+
|
|
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
|
+
}
|
|
1423
1591
|
`;
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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; }
|
|
1427
1813
|
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
+
}
|
|
1430
1820
|
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1821
|
+
const toolInput = payload.tool_input || {};
|
|
1822
|
+
const sessionId = payload.session_id || '';
|
|
1823
|
+
const cwd = payload.cwd || '';
|
|
1824
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1434
1825
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
1826
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1827
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1438
1828
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
(
|
|
1442
|
-
_LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
|
|
1443
|
-
CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
|
|
1444
|
-
TOTALS=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null \\
|
|
1445
|
-
| jq -c '.message.usage' 2>/dev/null \\
|
|
1446
|
-
| jq -s '{in:(map(.input_tokens//0)|add//0),out:(map(.output_tokens//0)|add//0),cw:(map(.cache_creation_input_tokens//0)|add//0),cr:(map(.cache_read_input_tokens//0)|add//0)}' 2>/dev/null \\
|
|
1447
|
-
|| echo '{"in":0,"out":0,"cw":0,"cr":0}')
|
|
1448
|
-
TOK_IN=$(echo "$TOTALS" | jq -r '.in // 0')
|
|
1449
|
-
TOK_OUT=$(echo "$TOTALS" | jq -r '.out // 0')
|
|
1450
|
-
TOK_CW=$(echo "$TOTALS" | jq -r '.cw // 0')
|
|
1451
|
-
TOK_CR=$(echo "$TOTALS" | jq -r '.cr // 0')
|
|
1452
|
-
HAS_TOKENS=$(( TOK_IN + TOK_OUT ))
|
|
1453
|
-
if [ "$HAS_TOKENS" != "0" ]; then
|
|
1454
|
-
CC_USAGE="{"input_tokens":$TOK_IN,"output_tokens":$TOK_OUT,"cache_creation_input_tokens":$TOK_CW,"cache_read_input_tokens":$TOK_CR}"
|
|
1455
|
-
BODY=$(jq -n \\
|
|
1456
|
-
--arg event_id "usage_$(date +%s)_$$" \\
|
|
1457
|
-
--arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
|
|
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
|
|
1829
|
+
const fileShort = basename(filePath);
|
|
1830
|
+
const fileExt = extname(filePath); // e.g. ".ts"
|
|
1472
1831
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1832
|
+
let jwt = loadJwt();
|
|
1833
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1834
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1476
1835
|
|
|
1477
|
-
|
|
1836
|
+
// Reconstruct proposed content
|
|
1837
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1838
|
+
if (!proposed) { outputEmpty(); return; }
|
|
1478
1839
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
AUTO_FIXED=$(echo "$RESP" | jq -r '.auto_fixed // 0' 2>/dev/null)
|
|
1482
|
-
OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
|
|
1840
|
+
const config = await loadConfig(jwt);
|
|
1841
|
+
const rt = await cweRoute(config);
|
|
1483
1842
|
|
|
1484
|
-
if
|
|
1843
|
+
if (config.silent) {
|
|
1844
|
+
outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1485
1847
|
|
|
1486
|
-
|
|
1487
|
-
TAG=$(synkro_tag)
|
|
1488
|
-
if [ "\${FINDINGS:-0}" = "0" ] || [ -z "$FINDINGS" ]; then
|
|
1489
|
-
SYS_MSG="$TAG stop \u2192 0 issues across \${EDITS} edit(s), session complete"
|
|
1490
|
-
else
|
|
1491
|
-
SYS_MSG="$TAG stop \u2192 \${FINDINGS} finding(s): \${AUTO_FIXED} auto-fixed, \${OPEN} open"
|
|
1492
|
-
fi
|
|
1848
|
+
const cweTag = '[synkro:' + rt + ':cweScan]';
|
|
1493
1849
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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 {}
|
|
1500
1861
|
|
|
1501
|
-
|
|
1862
|
+
if (cweRules.length === 0) {
|
|
1863
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (no CWE rules for ' + fileExt + ')' });
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1502
1866
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
+
}
|
|
1509
1884
|
|
|
1510
|
-
|
|
1511
|
-
if [ -n "$JWT" ]; then
|
|
1512
|
-
RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/hook/config" \\
|
|
1513
|
-
--data-urlencode "session_id=\${SESSION_ID:-}" \\
|
|
1514
|
-
--data-urlencode "repo=\${GIT_REPO:-}" \\
|
|
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
|
|
1885
|
+
const verdict = parseVerdict(gradeResp);
|
|
1521
1886
|
|
|
1522
|
-
if (
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
+
}
|
|
1530
1908
|
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
fi
|
|
1909
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1535
1912
|
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
+
}
|
|
1540
1921
|
|
|
1541
|
-
|
|
1542
|
-
jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
|
|
1543
|
-
else
|
|
1544
|
-
if [ "$OPEN" = "1" ]; then
|
|
1545
|
-
SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 1 open finding in this repo from a prior session."
|
|
1546
|
-
else
|
|
1547
|
-
SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 \${OPEN} open findings in this repo from prior sessions."
|
|
1548
|
-
fi
|
|
1549
|
-
jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
|
|
1550
|
-
fi
|
|
1551
|
-
exit 0
|
|
1922
|
+
main();
|
|
1552
1923
|
`;
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
+
}
|
|
1556
1944
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1945
|
+
async function main() {
|
|
1946
|
+
try {
|
|
1947
|
+
const input = await readStdin();
|
|
1948
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1559
1949
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
if [
|
|
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
|
+
}
|
|
1563
1956
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
if [ -z "$SESSION_ID" ] || [ -z "$TOOL_USE_ID" ]; then echo '{}'; exit 0; fi
|
|
1957
|
+
const toolInput = payload.tool_input || {};
|
|
1958
|
+
const cwd = payload.cwd || '';
|
|
1567
1959
|
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
|
1960
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1961
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1575
1962
|
|
|
1576
|
-
|
|
1577
|
-
--argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
|
|
1578
|
-
'{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
|
|
1963
|
+
const fileShort = basename(filePath);
|
|
1579
1964
|
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
# Consent tracking: on success \u2192 consume (next run blocks fresh), on error \u2192 grant (retry allowed)
|
|
1584
|
-
if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
|
|
1585
|
-
if [ "$IS_ERROR" = "false" ]; then
|
|
1586
|
-
synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
|
|
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
|
|
1965
|
+
let jwt = loadJwt();
|
|
1966
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1967
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1593
1968
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1 || true
|
|
1969
|
+
const config = await loadConfig(jwt);
|
|
1970
|
+
const rt = await route(config);
|
|
1597
1971
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1603
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1972
|
+
if (config.silent) {
|
|
1973
|
+
outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1604
1976
|
|
|
1605
|
-
|
|
1606
|
-
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
1607
|
-
synkro_ensure_fresh_jwt
|
|
1977
|
+
const cveTag = '[synkro:' + rt + ':cveScan]';
|
|
1608
1978
|
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
+
}
|
|
1611
1985
|
|
|
1612
|
-
|
|
1613
|
-
case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
|
|
1986
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1614
1987
|
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
+
}
|
|
1617
1993
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1994
|
+
// CVE scan via OSV API
|
|
1995
|
+
const cveBody = {
|
|
1996
|
+
file_path: filePath,
|
|
1997
|
+
content: proposedShort,
|
|
1998
|
+
dependencies: deps,
|
|
1999
|
+
};
|
|
1620
2000
|
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
+
}
|
|
1623
2014
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
+
}
|
|
1633
2036
|
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
2037
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
|
|
2040
|
+
outputEmpty();
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
1637
2043
|
|
|
1638
|
-
|
|
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';
|
|
1639
2054
|
|
|
1640
|
-
|
|
2055
|
+
async function main() {
|
|
2056
|
+
try {
|
|
2057
|
+
const input = await readStdin();
|
|
2058
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1641
2059
|
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
+
}
|
|
1644
2066
|
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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; }
|
|
1648
2083
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
fi
|
|
2084
|
+
const cmdShort = command.slice(0, 80);
|
|
2085
|
+
log('bashGuard checking: ' + cmdShort);
|
|
1652
2086
|
|
|
1653
|
-
|
|
1654
|
-
if
|
|
1655
|
-
|
|
1656
|
-
CRIT_PKGS=$(echo "$RESP" | jq -r '[.findings[] | select((.severity | tonumber? // 0) >= 7.0) | .package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
|
|
1657
|
-
ALL_PKGS=$(echo "$RESP" | jq -r '[.findings[].package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
|
|
1658
|
-
ALL_TOTAL=$(echo "$RESP" | jq -r '[.findings[].package] | unique | length' 2>/dev/null || echo "0")
|
|
1659
|
-
[ "$CVE_COUNT" = "1" ] && LABEL="advisory" || LABEL="advisories"
|
|
1660
|
-
if [ "$CVE_CRIT" -gt 0 ]; then
|
|
1661
|
-
[ "$ALL_TOTAL" -gt 3 ] && CRIT_PKGS="\${CRIT_PKGS}, ..."
|
|
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"
|
|
2087
|
+
let jwt = loadJwt();
|
|
2088
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2089
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1675
2090
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
synkro_ensure_fresh_jwt
|
|
2091
|
+
const transcript = extractTranscript(transcriptPath);
|
|
2092
|
+
const lastPrompt = readLastPrompt();
|
|
1679
2093
|
|
|
1680
|
-
|
|
1681
|
-
|
|
2094
|
+
const config = await loadConfig(jwt);
|
|
2095
|
+
const rt = await route(config);
|
|
2096
|
+
const tagStr = tag(rt, config);
|
|
1682
2097
|
|
|
1683
|
-
|
|
1684
|
-
|
|
2098
|
+
if (config.silent) {
|
|
2099
|
+
outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 skipped (silent mode)' });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
1685
2102
|
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
+
}
|
|
1688
2120
|
|
|
1689
|
-
|
|
1690
|
-
|
|
2121
|
+
const verdict = parseVerdict(gradeResp);
|
|
2122
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
1691
2123
|
|
|
1692
|
-
|
|
1693
|
-
|
|
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');
|
|
1694
2127
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
+
}
|
|
1698
2159
|
|
|
1699
|
-
|
|
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
|
+
};
|
|
1700
2183
|
|
|
1701
|
-
|
|
1702
|
-
FILE_EXT=".\${FILE_PATH##*.}"
|
|
2184
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
|
|
1703
2185
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
[ "$total" -gt 3 ] && ids="\${ids}, ..."
|
|
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
|
|
1715
|
-
}
|
|
2186
|
+
if (!resp) {
|
|
2187
|
+
log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
|
|
2188
|
+
outputEmpty();
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
1716
2191
|
|
|
1717
|
-
if
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
|
2192
|
+
if (!resp.hook_response || typeof resp.hook_response !== 'object') {
|
|
2193
|
+
log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
|
|
2194
|
+
outputEmpty();
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
1727
2197
|
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
2198
|
+
outputJson(resp.hook_response);
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
|
|
2201
|
+
outputEmpty();
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
1731
2204
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
fi
|
|
1758
|
-
else
|
|
1759
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
|
|
1760
|
-
fi
|
|
1761
|
-
exit 0
|
|
1762
|
-
fi
|
|
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
|
+
}
|
|
1763
2230
|
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
+
}
|
|
1767
2240
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2241
|
+
async function main() {
|
|
2242
|
+
try {
|
|
2243
|
+
const input = await readStdin();
|
|
2244
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1771
2245
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
2246
|
+
const payload = JSON.parse(input);
|
|
2247
|
+
const toolName = payload.tool_name || '';
|
|
2248
|
+
if (toolName !== 'ExitPlanMode') { outputEmpty(); return; }
|
|
1775
2249
|
|
|
1776
|
-
|
|
1777
|
-
if
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
CWE_TOTAL=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
|
|
1781
|
-
format_cwe_result "$CWE_COUNT" "$CWE_CRIT" "$CWE_IDS" "$CWE_TOTAL"
|
|
1782
|
-
else
|
|
1783
|
-
jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
|
|
1784
|
-
fi
|
|
1785
|
-
exit 0
|
|
1786
|
-
`;
|
|
1787
|
-
CC_TRANSCRIPT_SYNC_SCRIPT = `#!/bin/bash
|
|
1788
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1789
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
2250
|
+
const planFile = findLatestPlan();
|
|
2251
|
+
if (!planFile) { outputEmpty(); return; }
|
|
2252
|
+
const plan = readFileSync(planFile, 'utf-8');
|
|
2253
|
+
if (plan.length < 20) { outputEmpty(); return; }
|
|
1790
2254
|
|
|
1791
|
-
|
|
1792
|
-
|
|
2255
|
+
const sessionId = payload.session_id || '';
|
|
2256
|
+
const cwd = payload.cwd || '';
|
|
2257
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1793
2258
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
1797
|
-
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
2259
|
+
const planShort = plan.slice(0, 80);
|
|
2260
|
+
log('planReview checking: ' + planShort + '...');
|
|
1798
2261
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2262
|
+
let jwt = loadJwt();
|
|
2263
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2264
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1802
2265
|
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
|
2266
|
+
const config = await loadConfig(jwt);
|
|
2267
|
+
const rt = await route(config);
|
|
2268
|
+
const tagStr = tag(rt, config);
|
|
1829
2269
|
|
|
1830
|
-
|
|
1831
|
-
|
|
2270
|
+
if (config.silent) {
|
|
2271
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
1832
2274
|
|
|
1833
|
-
|
|
1834
|
-
|
|
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');
|
|
1835
2283
|
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
2284
|
+
let gradeResp: string;
|
|
2285
|
+
try {
|
|
2286
|
+
gradeResp = await localGrade('plan', graderPrompt);
|
|
2287
|
+
} catch {
|
|
2288
|
+
outputEmpty();
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
1840
2291
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
+
}
|
|
1846
2317
|
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
+
};
|
|
1849
2327
|
|
|
1850
|
-
|
|
1851
|
-
START_LINE=$((OFFSET + 1))
|
|
1852
|
-
[ "$DELTA" -gt 200 ] && START_LINE=$((TOTAL_LINES - 199))
|
|
2328
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
|
|
1853
2329
|
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
type: $line.type,
|
|
1860
|
-
content: (if $line.type == "user" then ($line.message.content | if type == "string" then .[0:8000] else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:8000]) end) else ([$line.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:8000]) end),
|
|
1861
|
-
tool_calls: (if $line.type == "assistant" then [$line.message.content[]? | select(.type == "tool_use") | {name, input: (.input | tostring | .[0:500]), id}] else null end | if . == null or length == 0 then null else . end),
|
|
1862
|
-
model: ($line.message.model // null),
|
|
1863
|
-
usage: (if $line.type == "assistant" and $line.message.usage then {input_tokens: $line.message.usage.input_tokens, output_tokens: $line.message.usage.output_tokens, cache_creation_input_tokens: $line.message.usage.cache_creation_input_tokens, cache_read_input_tokens: $line.message.usage.cache_read_input_tokens} else null end)
|
|
1864
|
-
}
|
|
1865
|
-
else empty end
|
|
1866
|
-
' 2>/dev/null | jq -s '.' 2>/dev/null)
|
|
1867
|
-
|
|
1868
|
-
if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
|
|
1869
|
-
printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
|
|
1870
|
-
echo '{}'; exit 0
|
|
1871
|
-
fi
|
|
2330
|
+
if (!resp) {
|
|
2331
|
+
log('planReview \\u2192 error (timeout)');
|
|
2332
|
+
outputEmpty();
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
1872
2335
|
|
|
1873
|
-
|
|
1874
|
-
|
|
2336
|
+
const hookResp = resp?.hook_response;
|
|
2337
|
+
if (!hookResp) { outputEmpty(); return; }
|
|
1875
2338
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
)
|
|
1881
|
-
|
|
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
|
+
}
|
|
1882
2354
|
|
|
1883
|
-
|
|
1884
|
-
echo '{}'; exit 0
|
|
2355
|
+
main();
|
|
1885
2356
|
`;
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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';
|
|
1889
2362
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
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);
|
|
2429
|
+
|
|
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();
|
|
2442
|
+
`;
|
|
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';
|
|
1893
2449
|
|
|
1894
|
-
|
|
1895
|
-
|
|
2450
|
+
async function main() {
|
|
2451
|
+
try {
|
|
2452
|
+
const input = await readStdin();
|
|
2453
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1896
2454
|
|
|
1897
|
-
|
|
1898
|
-
|
|
2455
|
+
const payload = JSON.parse(input);
|
|
2456
|
+
const cwd = payload.cwd || '';
|
|
2457
|
+
const sessionId = payload.session_id || '';
|
|
2458
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1899
2459
|
|
|
1900
|
-
|
|
1901
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
1902
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
2460
|
+
let jwt = loadJwt();
|
|
1903
2461
|
|
|
1904
|
-
|
|
1905
|
-
|
|
2462
|
+
const isChannelUp = await channelUp();
|
|
2463
|
+
const rt = isChannelUp ? 'local' : 'cloud';
|
|
1906
2464
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
fi
|
|
2465
|
+
let policyName = '';
|
|
2466
|
+
let silent = false;
|
|
2467
|
+
let openFindings = 0;
|
|
1911
2468
|
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
}')
|
|
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
|
+
}
|
|
1926
2482
|
|
|
1927
|
-
|
|
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)');
|
|
1928
2486
|
|
|
1929
|
-
if
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
2487
|
+
if (!jwt) {
|
|
2488
|
+
outputJson({ systemMessage: routeLine });
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
1933
2491
|
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
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
|
+
}
|
|
2504
|
+
|
|
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
|
});
|
|
@@ -3850,9 +4465,14 @@ function findTask(channel = CHANNEL_PRIMARY) {
|
|
|
3850
4465
|
function startTask(opts = {}) {
|
|
3851
4466
|
const ch = opts.channel ?? CHANNEL_PRIMARY;
|
|
3852
4467
|
const cwd = opts.cwd ?? ch.sessionDir;
|
|
3853
|
-
|
|
3854
|
-
|
|
4468
|
+
let existing = findTask(ch);
|
|
4469
|
+
while (existing) {
|
|
4470
|
+
if (existing.status === "Running") {
|
|
4471
|
+
spawnSync2("tmux", ["kill-session", "-t", ch.tmuxSession], { encoding: "utf-8" });
|
|
4472
|
+
spawnSync2("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
|
|
4473
|
+
}
|
|
3855
4474
|
spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
|
|
4475
|
+
existing = findTask(ch);
|
|
3856
4476
|
}
|
|
3857
4477
|
const runScript = join8(cwd, "run-claude.sh");
|
|
3858
4478
|
const args2 = [
|
|
@@ -4280,7 +4900,7 @@ __export(install_exports, {
|
|
|
4280
4900
|
import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync10, readdirSync } from "fs";
|
|
4281
4901
|
import { homedir as homedir10 } from "os";
|
|
4282
4902
|
import { join as join11 } from "path";
|
|
4283
|
-
import { execSync as execSync5 } from "child_process";
|
|
4903
|
+
import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
|
|
4284
4904
|
import { createInterface as createInterface3 } from "readline";
|
|
4285
4905
|
function sanitizeGatewayCandidate(raw) {
|
|
4286
4906
|
if (!raw) return void 0;
|
|
@@ -4322,50 +4942,50 @@ function ensureSynkroDir() {
|
|
|
4322
4942
|
mkdirSync8(OFFSETS_DIR, { recursive: true });
|
|
4323
4943
|
}
|
|
4324
4944
|
function writeHookScripts() {
|
|
4325
|
-
const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.
|
|
4326
|
-
const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.
|
|
4327
|
-
const
|
|
4328
|
-
const
|
|
4329
|
-
const
|
|
4330
|
-
const
|
|
4331
|
-
const
|
|
4332
|
-
const
|
|
4333
|
-
const
|
|
4334
|
-
const
|
|
4335
|
-
const
|
|
4336
|
-
const
|
|
4945
|
+
const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
|
|
4946
|
+
const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
|
|
4947
|
+
const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
|
|
4948
|
+
const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
|
|
4949
|
+
const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
|
|
4950
|
+
const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
|
|
4951
|
+
const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
|
|
4952
|
+
const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
|
|
4953
|
+
const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
|
|
4954
|
+
const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
|
|
4955
|
+
const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
|
|
4956
|
+
const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
|
|
4337
4957
|
const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
|
|
4338
4958
|
const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
|
|
4339
4959
|
const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
|
|
4340
4960
|
const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
|
|
4341
|
-
writeFileSync7(bashScriptPath,
|
|
4342
|
-
writeFileSync7(bashFollowupScriptPath,
|
|
4343
|
-
writeFileSync7(
|
|
4344
|
-
writeFileSync7(
|
|
4345
|
-
writeFileSync7(
|
|
4346
|
-
writeFileSync7(
|
|
4347
|
-
writeFileSync7(
|
|
4348
|
-
writeFileSync7(
|
|
4349
|
-
writeFileSync7(
|
|
4350
|
-
writeFileSync7(
|
|
4351
|
-
writeFileSync7(
|
|
4352
|
-
writeFileSync7(
|
|
4961
|
+
writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
4962
|
+
writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
4963
|
+
writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
|
|
4964
|
+
writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
|
|
4965
|
+
writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
|
|
4966
|
+
writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
|
|
4967
|
+
writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
|
|
4968
|
+
writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
|
|
4969
|
+
writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
|
|
4970
|
+
writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
|
|
4971
|
+
writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
|
|
4972
|
+
writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
|
|
4353
4973
|
writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
|
|
4354
4974
|
writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
|
|
4355
4975
|
writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
|
|
4356
4976
|
writeFileSync7(cursorBashFollowupPath, CURSOR_BASH_FOLLOWUP_SCRIPT, "utf-8");
|
|
4357
4977
|
chmodSync2(bashScriptPath, 493);
|
|
4358
4978
|
chmodSync2(bashFollowupScriptPath, 493);
|
|
4359
|
-
chmodSync2(editCaptureScriptPath, 493);
|
|
4360
4979
|
chmodSync2(editPrecheckScriptPath, 493);
|
|
4361
|
-
chmodSync2(
|
|
4362
|
-
chmodSync2(
|
|
4980
|
+
chmodSync2(cwePrecheckScriptPath, 493);
|
|
4981
|
+
chmodSync2(cvePrecheckScriptPath, 493);
|
|
4363
4982
|
chmodSync2(planJudgeScriptPath, 493);
|
|
4364
4983
|
chmodSync2(stopSummaryScriptPath, 493);
|
|
4365
4984
|
chmodSync2(sessionStartScriptPath, 493);
|
|
4366
4985
|
chmodSync2(transcriptSyncScriptPath, 493);
|
|
4367
4986
|
chmodSync2(userPromptSubmitScriptPath, 493);
|
|
4368
4987
|
chmodSync2(commonScriptPath, 493);
|
|
4988
|
+
chmodSync2(commonBashScriptPath, 493);
|
|
4369
4989
|
chmodSync2(cursorBashJudgePath, 493);
|
|
4370
4990
|
chmodSync2(cursorEditPrecheckPath, 493);
|
|
4371
4991
|
chmodSync2(cursorEditCapturePath, 493);
|
|
@@ -4373,10 +4993,9 @@ function writeHookScripts() {
|
|
|
4373
4993
|
return {
|
|
4374
4994
|
bashScript: bashScriptPath,
|
|
4375
4995
|
bashFollowupScript: bashFollowupScriptPath,
|
|
4376
|
-
editCaptureScript: editCaptureScriptPath,
|
|
4377
4996
|
editPrecheckScript: editPrecheckScriptPath,
|
|
4378
|
-
|
|
4379
|
-
|
|
4997
|
+
cwePrecheckScript: cwePrecheckScriptPath,
|
|
4998
|
+
cvePrecheckScript: cvePrecheckScriptPath,
|
|
4380
4999
|
planJudgeScript: planJudgeScriptPath,
|
|
4381
5000
|
stopSummaryScript: stopSummaryScriptPath,
|
|
4382
5001
|
sessionStartScript: sessionStartScriptPath,
|
|
@@ -4417,7 +5036,7 @@ function writeConfigEnv(opts) {
|
|
|
4417
5036
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
4418
5037
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
4419
5038
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
4420
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.4.
|
|
5039
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.4.48")}`
|
|
4421
5040
|
];
|
|
4422
5041
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
4423
5042
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -4542,13 +5161,14 @@ function assertGatewayAllowed(gatewayUrl) {
|
|
|
4542
5161
|
}
|
|
4543
5162
|
function isAlreadyInstalled() {
|
|
4544
5163
|
const requiredScripts = [
|
|
4545
|
-
join11(HOOKS_DIR, "cc-bash-judge.
|
|
4546
|
-
join11(HOOKS_DIR, "cc-bash-followup.
|
|
4547
|
-
join11(HOOKS_DIR, "cc-edit-precheck.
|
|
4548
|
-
join11(HOOKS_DIR, "cc-
|
|
4549
|
-
join11(HOOKS_DIR, "cc-
|
|
4550
|
-
join11(HOOKS_DIR, "cc-
|
|
4551
|
-
join11(HOOKS_DIR, "cc-
|
|
5164
|
+
join11(HOOKS_DIR, "cc-bash-judge.ts"),
|
|
5165
|
+
join11(HOOKS_DIR, "cc-bash-followup.ts"),
|
|
5166
|
+
join11(HOOKS_DIR, "cc-edit-precheck.ts"),
|
|
5167
|
+
join11(HOOKS_DIR, "cc-cwe-precheck.ts"),
|
|
5168
|
+
join11(HOOKS_DIR, "cc-cve-precheck.ts"),
|
|
5169
|
+
join11(HOOKS_DIR, "cc-plan-judge.ts"),
|
|
5170
|
+
join11(HOOKS_DIR, "cc-stop-summary.ts"),
|
|
5171
|
+
join11(HOOKS_DIR, "cc-session-start.ts")
|
|
4552
5172
|
];
|
|
4553
5173
|
if (!requiredScripts.every((p) => existsSync11(p))) return false;
|
|
4554
5174
|
if (!existsSync11(CONFIG_PATH3)) return false;
|
|
@@ -4568,6 +5188,35 @@ function isAlreadyInstalled() {
|
|
|
4568
5188
|
}
|
|
4569
5189
|
return true;
|
|
4570
5190
|
}
|
|
5191
|
+
function printChannelDiagnostics() {
|
|
5192
|
+
try {
|
|
5193
|
+
const tmuxCheck = spawnSync3("tmux", ["has-session", "-t", "synkro-local-cc"], { encoding: "utf-8" });
|
|
5194
|
+
console.warn(` tmux session: ${tmuxCheck.status === 0 ? "running" : "not running"}`);
|
|
5195
|
+
const pueueTask = findTask();
|
|
5196
|
+
console.warn(` pueue task: ${pueueTask ? `id=${pueueTask.id} status=${pueueTask.status}` : "not found"}`);
|
|
5197
|
+
const bunCheck = spawnSync3("bun", ["--version"], { encoding: "utf-8" });
|
|
5198
|
+
console.warn(` bun: ${bunCheck.status === 0 ? bunCheck.stdout.trim() : "not found"}`);
|
|
5199
|
+
const claudeCheck = spawnSync3("claude", ["--version"], { encoding: "utf-8" });
|
|
5200
|
+
console.warn(` claude: ${claudeCheck.status === 0 ? claudeCheck.stdout.trim().split("\n")[0] : "not found"}`);
|
|
5201
|
+
if (pueueTask) {
|
|
5202
|
+
const logs = tailLogs(15);
|
|
5203
|
+
if (logs && logs !== "(no output)") {
|
|
5204
|
+
console.warn(` pueue logs (last 15 lines):`);
|
|
5205
|
+
for (const line of logs.split("\n").slice(0, 15)) {
|
|
5206
|
+
console.warn(` ${line}`);
|
|
5207
|
+
}
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
5210
|
+
const logPath = join11(homedir10(), ".synkro", "cc_sessions", "run-claude.log");
|
|
5211
|
+
if (existsSync11(logPath)) {
|
|
5212
|
+
const logContent = readFileSync10(logPath, "utf-8").trim().split("\n").slice(-10);
|
|
5213
|
+
console.warn(` run-claude.log:`);
|
|
5214
|
+
for (const line of logContent) console.warn(` ${line}`);
|
|
5215
|
+
}
|
|
5216
|
+
} catch {
|
|
5217
|
+
}
|
|
5218
|
+
console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.`);
|
|
5219
|
+
}
|
|
4571
5220
|
async function installCommand(opts = {}) {
|
|
4572
5221
|
const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
|
|
4573
5222
|
try {
|
|
@@ -4605,17 +5254,18 @@ async function installCommand(opts = {}) {
|
|
|
4605
5254
|
assertPueueInstalled();
|
|
4606
5255
|
assertTmuxInstalled();
|
|
4607
5256
|
installLocalCC();
|
|
4608
|
-
const
|
|
4609
|
-
console.log(` pueue task: id=${t.id} status=${t.status}`);
|
|
4610
|
-
console.log(" Waiting for channel...");
|
|
4611
|
-
const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
|
|
4612
|
-
if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
4613
|
-
else console.warn(" \u26A0 channel did not come up within 60s \u2014 check `synkro local-cc logs`");
|
|
5257
|
+
const t1 = ensureRunning();
|
|
4614
5258
|
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
4615
|
-
console.log(`
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
5259
|
+
console.log(` channel 1: pueue id=${t1.id} channel 2: pueue id=${t2.id}`);
|
|
5260
|
+
console.log(" Waiting for both channels...");
|
|
5261
|
+
const [ready1, ready2] = await Promise.all([
|
|
5262
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST),
|
|
5263
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
5264
|
+
]);
|
|
5265
|
+
if (ready1) console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
5266
|
+
else console.warn(" \u26A0 channel 1 did not come up within 60s \u2014 check `synkro local-cc logs`");
|
|
5267
|
+
if (ready2) console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
|
|
5268
|
+
else console.warn(" \u26A0 channel 2 did not come up within 60s");
|
|
4619
5269
|
updateLocalInferenceFlag(true);
|
|
4620
5270
|
} catch (err) {
|
|
4621
5271
|
console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
|
|
@@ -4688,7 +5338,6 @@ async function installCommand(opts = {}) {
|
|
|
4688
5338
|
console.log("Wrote hook scripts:");
|
|
4689
5339
|
console.log(` ${scripts.bashScript}`);
|
|
4690
5340
|
console.log(` ${scripts.bashFollowupScript}`);
|
|
4691
|
-
console.log(` ${scripts.editCaptureScript}`);
|
|
4692
5341
|
console.log(` ${scripts.editPrecheckScript}`);
|
|
4693
5342
|
console.log(` ${scripts.planJudgeScript}`);
|
|
4694
5343
|
console.log(` ${scripts.stopSummaryScript}`);
|
|
@@ -4723,10 +5372,9 @@ async function installCommand(opts = {}) {
|
|
|
4723
5372
|
installCCHooks(agent.settingsPath, {
|
|
4724
5373
|
bashJudgeScriptPath: scripts.bashScript,
|
|
4725
5374
|
bashFollowupScriptPath: scripts.bashFollowupScript,
|
|
4726
|
-
editCaptureScriptPath: scripts.editCaptureScript,
|
|
4727
5375
|
editPrecheckScriptPath: scripts.editPrecheckScript,
|
|
4728
|
-
|
|
4729
|
-
|
|
5376
|
+
cwePrecheckScriptPath: scripts.cwePrecheckScript,
|
|
5377
|
+
cvePrecheckScriptPath: scripts.cvePrecheckScript,
|
|
4730
5378
|
planJudgeScriptPath: scripts.planJudgeScript,
|
|
4731
5379
|
stopSummaryScriptPath: scripts.stopSummaryScript,
|
|
4732
5380
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
@@ -4825,72 +5473,62 @@ async function installCommand(opts = {}) {
|
|
|
4825
5473
|
console.warn(` \u26A0 Some dependencies missing \u2014 \`synkro local-cc enable\` may not work until they're installed.`);
|
|
4826
5474
|
}
|
|
4827
5475
|
console.log();
|
|
4828
|
-
|
|
5476
|
+
const priorLocalFlag = (() => {
|
|
5477
|
+
try {
|
|
5478
|
+
const content = readFileSync10(CONFIG_PATH3, "utf-8");
|
|
5479
|
+
return content.includes("SYNKRO_LOCAL_INFERENCE='yes'");
|
|
5480
|
+
} catch {
|
|
5481
|
+
return false;
|
|
5482
|
+
}
|
|
5483
|
+
})();
|
|
5484
|
+
if (profile.localInference || priorLocalFlag && localCcDeps.length === 3) {
|
|
4829
5485
|
try {
|
|
4830
5486
|
const r = installLocalCC();
|
|
4831
5487
|
console.log(`Installed local-CC channel plugin at ${r.pluginPath}`);
|
|
4832
|
-
const
|
|
4833
|
-
|
|
4834
|
-
console.log(
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
console.log(" warmup skipped (non-fatal)\n");
|
|
4844
|
-
}
|
|
5488
|
+
const t1 = ensureRunning();
|
|
5489
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
5490
|
+
console.log(`Channel 1 (org rules): pueue id=${t1.id} status=${t1.status}`);
|
|
5491
|
+
console.log(`Channel 2 (CWE scan): pueue id=${t2.id} status=${t2.status}`);
|
|
5492
|
+
console.log("Waiting for both channels (up to 60s)...");
|
|
5493
|
+
const [ready1, ready2] = await Promise.all([
|
|
5494
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST),
|
|
5495
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
5496
|
+
]);
|
|
5497
|
+
if (ready1) {
|
|
5498
|
+
console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
4845
5499
|
} else {
|
|
4846
|
-
console.warn(` \u26A0 Channel did not come up within 60s.`);
|
|
4847
|
-
|
|
4848
|
-
const { spawnSync: sp } = await import("child_process");
|
|
4849
|
-
const tmuxCheck = sp("tmux", ["has-session", "-t", "synkro-local-cc"], { encoding: "utf-8" });
|
|
4850
|
-
console.warn(` tmux session: ${tmuxCheck.status === 0 ? "running" : "not running"}`);
|
|
4851
|
-
const pueueTask = findTask();
|
|
4852
|
-
console.warn(` pueue task: ${pueueTask ? `id=${pueueTask.id} status=${pueueTask.status}` : "not found"}`);
|
|
4853
|
-
const bunCheck = sp("bun", ["--version"], { encoding: "utf-8" });
|
|
4854
|
-
console.warn(` bun: ${bunCheck.status === 0 ? bunCheck.stdout.trim() : "not found"}`);
|
|
4855
|
-
const claudeCheck = sp("claude", ["--version"], { encoding: "utf-8" });
|
|
4856
|
-
console.warn(` claude: ${claudeCheck.status === 0 ? claudeCheck.stdout.trim().split("\n")[0] : "not found"}`);
|
|
4857
|
-
if (pueueTask) {
|
|
4858
|
-
const logs = tailLogs(15);
|
|
4859
|
-
if (logs && logs !== "(no output)") {
|
|
4860
|
-
console.warn(` pueue logs (last 15 lines):`);
|
|
4861
|
-
for (const line of logs.split("\n").slice(0, 15)) {
|
|
4862
|
-
console.warn(` ${line}`);
|
|
4863
|
-
}
|
|
4864
|
-
}
|
|
4865
|
-
}
|
|
4866
|
-
const logPath = join11(homedir10(), ".synkro", "cc_sessions", "run-claude.log");
|
|
4867
|
-
if (existsSync11(logPath)) {
|
|
4868
|
-
const logContent = readFileSync10(logPath, "utf-8").trim().split("\n").slice(-10);
|
|
4869
|
-
console.warn(` run-claude.log:`);
|
|
4870
|
-
for (const line of logContent) console.warn(` ${line}`);
|
|
4871
|
-
}
|
|
4872
|
-
} catch {
|
|
4873
|
-
}
|
|
4874
|
-
console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.
|
|
4875
|
-
`);
|
|
5500
|
+
console.warn(` \u26A0 Channel 1 did not come up within 60s.`);
|
|
5501
|
+
printChannelDiagnostics();
|
|
4876
5502
|
}
|
|
4877
|
-
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
4878
|
-
console.log(`Local-CC CWE channel: id=${t2.id} status=${t2.status}`);
|
|
4879
|
-
console.log("Waiting for CWE channel (up to 60s)...");
|
|
4880
|
-
const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
|
|
4881
5503
|
if (ready2) {
|
|
4882
|
-
console.log(`
|
|
4883
|
-
try {
|
|
4884
|
-
console.log(" warming up CWE inference...");
|
|
4885
|
-
await submitToChannel("grade-cwe", 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n', { timeoutMs: 3e4, port: CHANNEL_2_PORT });
|
|
4886
|
-
console.log(" CWE inference warm\n");
|
|
4887
|
-
} catch {
|
|
4888
|
-
console.log(" CWE warmup skipped (non-fatal)\n");
|
|
4889
|
-
}
|
|
5504
|
+
console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
|
|
4890
5505
|
} else {
|
|
4891
|
-
console.warn(` \u26A0
|
|
4892
|
-
console.warn(` Run \`synkro local-cc status\` to debug
|
|
4893
|
-
|
|
5506
|
+
console.warn(` \u26A0 Channel 2 did not come up within 60s.`);
|
|
5507
|
+
console.warn(` Run \`synkro local-cc status\` to debug.`);
|
|
5508
|
+
}
|
|
5509
|
+
if (ready1 || ready2) {
|
|
5510
|
+
console.log("Warming up inference...");
|
|
5511
|
+
const warmups = [];
|
|
5512
|
+
if (ready1) {
|
|
5513
|
+
warmups.push(
|
|
5514
|
+
submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 }).then(() => {
|
|
5515
|
+
console.log(" channel 1 warm");
|
|
5516
|
+
}).catch(() => {
|
|
5517
|
+
console.log(" channel 1 warmup skipped (non-fatal)");
|
|
5518
|
+
})
|
|
5519
|
+
);
|
|
5520
|
+
}
|
|
5521
|
+
if (ready2) {
|
|
5522
|
+
warmups.push(
|
|
5523
|
+
submitToChannel("grade-cwe", 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n', { timeoutMs: 3e4, port: CHANNEL_2_PORT }).then(() => {
|
|
5524
|
+
console.log(" channel 2 warm");
|
|
5525
|
+
}).catch(() => {
|
|
5526
|
+
console.log(" channel 2 warmup skipped (non-fatal)");
|
|
5527
|
+
})
|
|
5528
|
+
);
|
|
5529
|
+
}
|
|
5530
|
+
await Promise.all(warmups);
|
|
5531
|
+
console.log();
|
|
4894
5532
|
}
|
|
4895
5533
|
} catch (err) {
|
|
4896
5534
|
console.warn(` \u26A0 Local-CC setup failed: ${err.message}
|
|
@@ -5131,6 +5769,7 @@ var init_install2 = __esm({
|
|
|
5131
5769
|
init_cursorHookConfig();
|
|
5132
5770
|
init_mcpConfig();
|
|
5133
5771
|
init_hookScripts();
|
|
5772
|
+
init_hookScriptsTs();
|
|
5134
5773
|
init_stub();
|
|
5135
5774
|
init_repoConnect();
|
|
5136
5775
|
init_projects();
|
|
@@ -6385,7 +7024,7 @@ var localCc_exports = {};
|
|
|
6385
7024
|
__export(localCc_exports, {
|
|
6386
7025
|
localCcCommand: () => localCcCommand
|
|
6387
7026
|
});
|
|
6388
|
-
import { spawnSync as
|
|
7027
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
6389
7028
|
import { homedir as homedir14 } from "os";
|
|
6390
7029
|
import { join as join16 } from "path";
|
|
6391
7030
|
import { existsSync as existsSync16, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
|
|
@@ -6529,7 +7168,7 @@ async function cmdStatus() {
|
|
|
6529
7168
|
}
|
|
6530
7169
|
const ch1Up = await isChannelAvailable();
|
|
6531
7170
|
console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
|
|
6532
|
-
const tmux1 =
|
|
7171
|
+
const tmux1 = spawnSync4("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
|
|
6533
7172
|
console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
|
|
6534
7173
|
const t2 = findTask(CHANNEL_SECONDARY);
|
|
6535
7174
|
if (!t2) {
|
|
@@ -6539,7 +7178,7 @@ async function cmdStatus() {
|
|
|
6539
7178
|
}
|
|
6540
7179
|
const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
|
|
6541
7180
|
console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
|
|
6542
|
-
const tmux2 =
|
|
7181
|
+
const tmux2 = spawnSync4("tmux", ["has-session", "-t", TMUX_SESSION_NAME_2], { encoding: "utf-8" });
|
|
6543
7182
|
console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
|
|
6544
7183
|
}
|
|
6545
7184
|
async function cmdEnable() {
|
|
@@ -6731,7 +7370,7 @@ function cmdLogs(rest) {
|
|
|
6731
7370
|
function cmdAttach(rest) {
|
|
6732
7371
|
assertTmuxInstalled();
|
|
6733
7372
|
const readonly = rest.some((a) => a === "--readonly" || a === "-r");
|
|
6734
|
-
const has =
|
|
7373
|
+
const has = spawnSync4("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
|
|
6735
7374
|
if (has.status !== 0) {
|
|
6736
7375
|
console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
|
|
6737
7376
|
process.exit(1);
|
|
@@ -6744,7 +7383,7 @@ function cmdAttach(rest) {
|
|
|
6744
7383
|
console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
|
|
6745
7384
|
console.log();
|
|
6746
7385
|
const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
|
|
6747
|
-
const r =
|
|
7386
|
+
const r = spawnSync4("tmux", args2, { stdio: "inherit" });
|
|
6748
7387
|
process.exit(r.status ?? 0);
|
|
6749
7388
|
}
|
|
6750
7389
|
async function cmdTest() {
|