@synkro-sh/cli 1.4.43 → 1.4.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap.js +2228 -1210
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -121,11 +121,13 @@ function installCCHooks(settingsPath, config) {
|
|
|
121
121
|
removeSynkroEntries(settings.hooks, "PostToolUse");
|
|
122
122
|
removeSynkroEntries(settings.hooks, "SessionEnd");
|
|
123
123
|
removeSynkroEntries(settings.hooks, "SessionStart");
|
|
124
|
+
removeSynkroEntries(settings.hooks, "UserPromptSubmit");
|
|
124
125
|
removeSynkroEntries(settings.hooks, "Stop");
|
|
125
126
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse ?? [];
|
|
126
127
|
settings.hooks.PostToolUse = settings.hooks.PostToolUse ?? [];
|
|
127
128
|
settings.hooks.SessionEnd = settings.hooks.SessionEnd ?? [];
|
|
128
129
|
settings.hooks.SessionStart = settings.hooks.SessionStart ?? [];
|
|
130
|
+
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit ?? [];
|
|
129
131
|
settings.hooks.PreToolUse.push({
|
|
130
132
|
matcher: "Bash|Read|Grep|Glob",
|
|
131
133
|
hooks: [
|
|
@@ -143,40 +145,40 @@ function installCCHooks(settingsPath, config) {
|
|
|
143
145
|
{
|
|
144
146
|
type: "command",
|
|
145
147
|
command: config.editPrecheckScriptPath,
|
|
146
|
-
timeout:
|
|
148
|
+
timeout: 30
|
|
147
149
|
}
|
|
148
150
|
],
|
|
149
151
|
[SYNKRO_MARKER]: true
|
|
150
152
|
});
|
|
151
153
|
settings.hooks.PreToolUse.push({
|
|
152
|
-
matcher: "
|
|
154
|
+
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
153
155
|
hooks: [
|
|
154
156
|
{
|
|
155
157
|
type: "command",
|
|
156
|
-
command: config.
|
|
157
|
-
timeout:
|
|
158
|
+
command: config.cwePrecheckScriptPath,
|
|
159
|
+
timeout: 30
|
|
158
160
|
}
|
|
159
161
|
],
|
|
160
162
|
[SYNKRO_MARKER]: true
|
|
161
163
|
});
|
|
162
|
-
settings.hooks.
|
|
164
|
+
settings.hooks.PreToolUse.push({
|
|
163
165
|
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
164
166
|
hooks: [
|
|
165
167
|
{
|
|
166
168
|
type: "command",
|
|
167
|
-
command: config.
|
|
168
|
-
timeout:
|
|
169
|
+
command: config.cvePrecheckScriptPath,
|
|
170
|
+
timeout: 10
|
|
169
171
|
}
|
|
170
172
|
],
|
|
171
173
|
[SYNKRO_MARKER]: true
|
|
172
174
|
});
|
|
173
|
-
settings.hooks.
|
|
174
|
-
matcher: "
|
|
175
|
+
settings.hooks.PreToolUse.push({
|
|
176
|
+
matcher: "ExitPlanMode",
|
|
175
177
|
hooks: [
|
|
176
178
|
{
|
|
177
179
|
type: "command",
|
|
178
|
-
command: config.
|
|
179
|
-
timeout:
|
|
180
|
+
command: config.planJudgeScriptPath,
|
|
181
|
+
timeout: 45
|
|
180
182
|
}
|
|
181
183
|
],
|
|
182
184
|
[SYNKRO_MARKER]: true
|
|
@@ -209,6 +211,16 @@ function installCCHooks(settingsPath, config) {
|
|
|
209
211
|
],
|
|
210
212
|
[SYNKRO_MARKER]: true
|
|
211
213
|
});
|
|
214
|
+
settings.hooks.UserPromptSubmit.push({
|
|
215
|
+
hooks: [
|
|
216
|
+
{
|
|
217
|
+
type: "command",
|
|
218
|
+
command: config.userPromptSubmitScriptPath,
|
|
219
|
+
timeout: 5
|
|
220
|
+
}
|
|
221
|
+
],
|
|
222
|
+
[SYNKRO_MARKER]: true
|
|
223
|
+
});
|
|
212
224
|
settings.hooks.Stop = settings.hooks.Stop ?? [];
|
|
213
225
|
removeSynkroEntries(settings.hooks, "Stop");
|
|
214
226
|
settings.hooks.Stop.push({
|
|
@@ -227,7 +239,7 @@ function uninstallCCHooks(settingsPath) {
|
|
|
227
239
|
if (!existsSync2(settingsPath)) return false;
|
|
228
240
|
const settings = readSettings(settingsPath);
|
|
229
241
|
if (!settings.hooks) return false;
|
|
230
|
-
const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop"];
|
|
242
|
+
const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop", "UserPromptSubmit"];
|
|
231
243
|
for (const evt of events) {
|
|
232
244
|
removeSynkroEntries(settings.hooks, evt);
|
|
233
245
|
}
|
|
@@ -452,7 +464,7 @@ var init_mcpConfig = __esm({
|
|
|
452
464
|
});
|
|
453
465
|
|
|
454
466
|
// cli/installer/hookScripts.ts
|
|
455
|
-
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;
|
|
456
468
|
var init_hookScripts = __esm({
|
|
457
469
|
"cli/installer/hookScripts.ts"() {
|
|
458
470
|
"use strict";
|
|
@@ -476,6 +488,25 @@ synkro_load_jwt() {
|
|
|
476
488
|
}
|
|
477
489
|
|
|
478
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
|
+
|
|
479
510
|
local rt
|
|
480
511
|
rt=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
|
|
481
512
|
if [ -z "$rt" ]; then return 1; fi
|
|
@@ -491,7 +522,12 @@ synkro_refresh_jwt() {
|
|
|
491
522
|
new_rt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
|
|
492
523
|
[ -z "$new_rt" ] && new_rt="$rt"
|
|
493
524
|
local tmp="\${CREDS_PATH}.synkro.tmp"
|
|
494
|
-
|
|
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"
|
|
495
531
|
JWT="$new_at"
|
|
496
532
|
}
|
|
497
533
|
|
|
@@ -531,8 +567,6 @@ synkro_load_config() {
|
|
|
531
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 "[]")
|
|
532
568
|
}
|
|
533
569
|
|
|
534
|
-
# Build the tag prefix: [synkro:route:ruleset] or [synkro:silent]
|
|
535
|
-
# Accepts optional $1 = route override; otherwise calls synkro_route().
|
|
536
570
|
synkro_tag() {
|
|
537
571
|
if [ "$SYNKRO_SILENT" = "true" ]; then echo "[synkro:silent]"; return; fi
|
|
538
572
|
local route="\${1:-$(synkro_route)}"
|
|
@@ -540,131 +574,12 @@ synkro_tag() {
|
|
|
540
574
|
echo "[synkro:\${route}:\${rs}]"
|
|
541
575
|
}
|
|
542
576
|
|
|
543
|
-
# Decide routing: "local" (grade on device) or "cloud" (POST to server)
|
|
544
577
|
synkro_route() {
|
|
545
578
|
[ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
|
|
546
579
|
synkro_channel_up && echo "local" && return
|
|
547
580
|
echo "cloud"
|
|
548
581
|
}
|
|
549
582
|
|
|
550
|
-
# Grade locally via synkro CLI channel. Reads prompt from stdin.
|
|
551
|
-
synkro_local_grade() {
|
|
552
|
-
local surface="$1"
|
|
553
|
-
if ! synkro_channel_up; then
|
|
554
|
-
echo "SYNKRO_CHANNEL_DOWN" >&2
|
|
555
|
-
return 1
|
|
556
|
-
fi
|
|
557
|
-
if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
|
|
558
|
-
node "$SYNKRO_CLI_BIN" grade "$surface" 2>/dev/null
|
|
559
|
-
elif command -v synkro >/dev/null 2>&1; then
|
|
560
|
-
synkro grade "$surface" 2>/dev/null
|
|
561
|
-
else
|
|
562
|
-
echo "SYNKRO_CLI_NOT_FOUND" >&2
|
|
563
|
-
return 1
|
|
564
|
-
fi
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
# Parse <synkro-verdict>...</synkro-verdict> XML from local grader output.
|
|
568
|
-
# Sets LOCAL_OK, LOCAL_REASON, LOCAL_RULE_ID, LOCAL_RULE_MODE, LOCAL_SEV, LOCAL_CAT.
|
|
569
|
-
synkro_parse_local_verdict() {
|
|
570
|
-
local resp="$1"
|
|
571
|
-
LOCAL_OK="true"; LOCAL_REASON=""; LOCAL_RULE_ID=""; LOCAL_RULE_MODE=""; LOCAL_SEV="low"; LOCAL_CAT="clean"
|
|
572
|
-
local inner
|
|
573
|
-
inner=$(printf '%s' "$resp" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
|
|
574
|
-
[ -z "$inner" ] && return
|
|
575
|
-
local ok_tag
|
|
576
|
-
ok_tag=$(printf '%s' "$inner" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
|
|
577
|
-
[ -n "$ok_tag" ] && LOCAL_OK="$ok_tag"
|
|
578
|
-
LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
|
|
579
|
-
[ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reasoning>(.*)</reasoning>.*|\\1|p' | head -1)
|
|
580
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
581
|
-
LOCAL_RULE_ID=$(printf '%s' "$inner" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
|
|
582
|
-
LOCAL_RULE_MODE=$(printf '%s' "$inner" | sed -nE 's|.*<rule_mode>(.*)</rule_mode>.*|\\1|p' | head -1)
|
|
583
|
-
LOCAL_SEV=$(printf '%s' "$inner" | sed -nE 's|.*<risk_level>(.*)</risk_level>.*|\\1|p' | head -1)
|
|
584
|
-
if [ -z "$LOCAL_RULE_ID" ]; then
|
|
585
|
-
local fv
|
|
586
|
-
fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
|
|
587
|
-
LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
|
|
588
|
-
[ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
|
|
589
|
-
[ -z "$LOCAL_SEV" ] && LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
|
|
590
|
-
fi
|
|
591
|
-
LOCAL_SEV="\${LOCAL_SEV:-high}"
|
|
592
|
-
LOCAL_CAT=$(printf '%s' "$inner" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
|
|
593
|
-
LOCAL_CAT="\${LOCAL_CAT:-uncategorized}"
|
|
594
|
-
[ -z "$LOCAL_RULE_ID" ] && LOCAL_RULE_ID=$(printf '%s' "$LOCAL_REASON" | grep -oE '[Rr][0-9]{3}' | head -1)
|
|
595
|
-
fi
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
# Fire anonymized telemetry for local verdicts. All args positional.
|
|
599
|
-
synkro_capture_local() {
|
|
600
|
-
local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
|
|
601
|
-
(
|
|
602
|
-
BODY=$(jq -n \\
|
|
603
|
-
--arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
|
|
604
|
-
--arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
|
|
605
|
-
--arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" \\
|
|
606
|
-
'{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,model:"claude-sonnet-4-6",tool_name:$tn}
|
|
607
|
-
+ (if $r != "" then {repo:$r} else {} end)
|
|
608
|
-
+ (if $sid != "" then {session_id:$sid} else {} end)')
|
|
609
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
610
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
611
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1
|
|
612
|
-
) &
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
# Fire full-content telemetry for local verdicts (used when capture_depth is full or evidence_on_violation).
|
|
616
|
-
synkro_capture_local_full() {
|
|
617
|
-
local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
|
|
618
|
-
local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
|
|
619
|
-
(
|
|
620
|
-
BODY=$(jq -n \\
|
|
621
|
-
--arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
|
|
622
|
-
--arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
|
|
623
|
-
--arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" \\
|
|
624
|
-
--arg cmd "$command" --arg rsn "$reasoning" --arg cd "$SYNKRO_CAPTURE_DEPTH" \\
|
|
625
|
-
--argjson rc "$rules_checked" --argjson vr "$violated_rules" --argjson rum "$recent_user_messages" \\
|
|
626
|
-
'{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,
|
|
627
|
-
model:"claude-sonnet-4-6",tool_name:$tn,capture_depth:$cd,
|
|
628
|
-
command:(if ($cmd|length) > 0 then $cmd else null end),
|
|
629
|
-
reasoning:(if ($rsn|length) > 0 then $rsn else null end),
|
|
630
|
-
rules_checked:$rc, violated_rules:$vr, recent_user_messages:$rum}
|
|
631
|
-
+ (if $r != "" then {repo:$r} else {} end)
|
|
632
|
-
+ (if $sid != "" then {session_id:$sid} else {} end)')
|
|
633
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
634
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
635
|
-
-d "$BODY" --max-time 3 >/dev/null 2>&1
|
|
636
|
-
) &
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
# Dispatch local verdict capture based on capture_depth privacy setting.
|
|
640
|
-
# For full: always send full content. For evidence_on_violation: full only on violations. For local_only: anonymized only.
|
|
641
|
-
synkro_dispatch_capture() {
|
|
642
|
-
local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
|
|
643
|
-
local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
|
|
644
|
-
local send_full=false
|
|
645
|
-
case "\${SYNKRO_CAPTURE_DEPTH:-local_only}" in
|
|
646
|
-
full) send_full=true ;;
|
|
647
|
-
evidence_on_violation)
|
|
648
|
-
case "$verdict" in block|warning|deny) send_full=true ;; esac ;;
|
|
649
|
-
esac
|
|
650
|
-
if [ "$send_full" = "true" ]; then
|
|
651
|
-
synkro_capture_local_full "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id" \\
|
|
652
|
-
"$command" "$reasoning" "$rules_checked" "$violated_rules" "$recent_user_messages"
|
|
653
|
-
else
|
|
654
|
-
synkro_capture_local "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id"
|
|
655
|
-
fi
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
# Look up a rule's mode from cached rules. Returns "blocking" or "audit".
|
|
659
|
-
synkro_rule_mode() {
|
|
660
|
-
local rid="$1"
|
|
661
|
-
[ -z "$rid" ] || [ -z "\${SYNKRO_RULES:-}" ] && echo "blocking" && return
|
|
662
|
-
local m
|
|
663
|
-
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)
|
|
664
|
-
[ -z "$m" ] || [ "$m" = "null" ] && m="blocking"
|
|
665
|
-
echo "$m"
|
|
666
|
-
}
|
|
667
|
-
|
|
668
583
|
SYNKRO_CONSENT_FILE="$HOME/.synkro/.local-consent"
|
|
669
584
|
|
|
670
585
|
_TAB=$(printf '\\t')
|
|
@@ -688,18 +603,6 @@ synkro_consent_consume() {
|
|
|
688
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
|
|
689
604
|
}
|
|
690
605
|
|
|
691
|
-
synkro_consent_has_consumed() {
|
|
692
|
-
local sid="$1" hash="$2"
|
|
693
|
-
grep -q "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" 2>/dev/null
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
synkro_consent_clear_consumed() {
|
|
697
|
-
local sid="$1" hash="$2"
|
|
698
|
-
[ ! -f "$SYNKRO_CONSENT_FILE" ] && return
|
|
699
|
-
local tmp="\${SYNKRO_CONSENT_FILE}.tmp"
|
|
700
|
-
grep -v "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
|
|
701
|
-
}
|
|
702
|
-
|
|
703
606
|
synkro_post_with_retry() {
|
|
704
607
|
local url="$1" body="$2" timeout="\${3:-8}"
|
|
705
608
|
local resp
|
|
@@ -718,7 +621,7 @@ synkro_post_with_retry() {
|
|
|
718
621
|
echo "$resp"
|
|
719
622
|
}
|
|
720
623
|
`;
|
|
721
|
-
|
|
624
|
+
CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
|
|
722
625
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
723
626
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
724
627
|
|
|
@@ -729,156 +632,52 @@ synkro_ensure_fresh_jwt
|
|
|
729
632
|
PAYLOAD=$(cat)
|
|
730
633
|
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
731
634
|
|
|
732
|
-
|
|
733
|
-
|
|
635
|
+
COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
|
|
636
|
+
if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
|
|
734
637
|
|
|
735
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
736
|
-
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
737
638
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
639
|
+
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
738
640
|
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
739
|
-
PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
|
|
740
|
-
|
|
741
|
-
# Translate tool calls to command string for logging
|
|
742
|
-
case "$TOOL_NAME" in
|
|
743
|
-
Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
|
|
744
|
-
Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
|
|
745
|
-
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)" ;;
|
|
746
|
-
Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
|
|
747
|
-
esac
|
|
748
|
-
if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
|
|
749
641
|
|
|
750
642
|
CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
|
|
751
643
|
synkro_log "bashGuard checking: $CMD_SHORT"
|
|
752
644
|
|
|
753
|
-
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
754
|
-
USER_INTENT=""
|
|
755
|
-
RECENT_USER_MESSAGES="[]"
|
|
756
|
-
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
757
|
-
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 "[]")
|
|
758
|
-
USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
|
|
759
|
-
fi
|
|
760
|
-
|
|
761
|
-
# Headless detection
|
|
762
|
-
IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
|
|
763
|
-
case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
|
|
764
|
-
|
|
765
645
|
synkro_load_config
|
|
766
|
-
ROUTE=$(synkro_route)
|
|
767
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
768
|
-
|
|
769
646
|
if [ "$SYNKRO_SILENT" = "true" ]; then
|
|
770
|
-
|
|
771
|
-
exit 0
|
|
772
|
-
fi
|
|
773
|
-
|
|
774
|
-
if [ "$ROUTE" = "local" ]; then
|
|
775
|
-
# \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
|
|
776
|
-
GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
|
|
777
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
778
|
-
printf 'Working directory: %s\\nRepo: %s\\nCommand: %s\\nUser intent (last human message): %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$COMMAND" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
|
|
779
|
-
|
|
780
|
-
CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" 2>&1)
|
|
781
|
-
if [ $? -ne 0 ]; then
|
|
782
|
-
jq -n --arg m "$TAG bashGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
|
|
783
|
-
exit 0
|
|
784
|
-
fi
|
|
785
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
786
|
-
|
|
787
|
-
# Build violated rules JSON for full-content capture
|
|
788
|
-
VIOLATED_JSON="[]"
|
|
789
|
-
[ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
790
|
-
|
|
791
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
792
|
-
RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
|
|
793
|
-
if [ "$RULE_MODE" = "audit" ]; then
|
|
794
|
-
REASON="$TAG bashGuard \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
|
|
795
|
-
jq -n --arg m "$REASON" '{systemMessage: $m}'
|
|
796
|
-
synkro_dispatch_capture "bash" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
797
|
-
"$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
|
|
798
|
-
else
|
|
799
|
-
REASON="$TAG bashGuard \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
|
|
800
|
-
jq -n --arg reason "$REASON" \\
|
|
801
|
-
'{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
|
|
802
|
-
synkro_dispatch_capture "bash" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
803
|
-
"$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
|
|
804
|
-
fi
|
|
805
|
-
else
|
|
806
|
-
jq -n --arg m "$TAG bashGuard \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
|
|
807
|
-
synkro_dispatch_capture "bash" "pass" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
808
|
-
"$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "\${RECENT_USER_MESSAGES:-[]}"
|
|
809
|
-
fi
|
|
810
|
-
exit 0
|
|
811
|
-
fi
|
|
812
|
-
|
|
813
|
-
# \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
814
|
-
CC_MODEL=""
|
|
815
|
-
CC_USAGE="{}"
|
|
816
|
-
RECENT_MESSAGES="[]"
|
|
817
|
-
RECENT_ACTIONS="[]"
|
|
818
|
-
SESSION_SUMMARY=""
|
|
819
|
-
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
820
|
-
_LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
|
|
821
|
-
if [ -n "$_LAST" ]; then
|
|
822
|
-
CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
|
|
823
|
-
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 "{}")
|
|
824
|
-
fi
|
|
825
|
-
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 "[]")
|
|
826
|
-
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 "[]")
|
|
827
|
-
SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
|
|
647
|
+
echo '{}'; exit 0
|
|
828
648
|
fi
|
|
829
649
|
|
|
830
650
|
BODY=$(jq -n \\
|
|
831
|
-
--arg
|
|
832
|
-
--arg tool_name "$TOOL_NAME" \\
|
|
833
|
-
--argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
|
|
834
|
-
--arg user_intent "$USER_INTENT" \\
|
|
835
|
-
--argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
|
|
836
|
-
--argjson recent_messages "$RECENT_MESSAGES" \\
|
|
837
|
-
--argjson recent_actions "$RECENT_ACTIONS" \\
|
|
651
|
+
--arg cmd "$COMMAND" \\
|
|
838
652
|
--arg session_id "$SESSION_ID" \\
|
|
839
|
-
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
840
653
|
--arg cwd "$CWD" \\
|
|
841
654
|
--arg repo "$GIT_REPO" \\
|
|
842
|
-
--arg permission_mode "$PERMISSION_MODE" \\
|
|
843
|
-
--arg cc_model "$CC_MODEL" \\
|
|
844
|
-
--argjson cc_usage "$CC_USAGE" \\
|
|
845
|
-
--arg session_summary "$SESSION_SUMMARY" \\
|
|
846
655
|
'{
|
|
847
|
-
hook_event:
|
|
848
|
-
tool_name:
|
|
849
|
-
tool_input: $
|
|
850
|
-
|
|
851
|
-
recent_user_messages: $recent_user_messages,
|
|
852
|
-
recent_messages: $recent_messages,
|
|
853
|
-
recent_actions: $recent_actions,
|
|
656
|
+
hook_event: "PreToolUse",
|
|
657
|
+
tool_name: "Bash",
|
|
658
|
+
tool_input: {command: $cmd},
|
|
659
|
+
response_format: "cursor",
|
|
854
660
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
855
|
-
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
856
661
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
857
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
858
|
-
permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
|
|
859
|
-
cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
|
|
860
|
-
cc_usage: $cc_usage,
|
|
861
|
-
session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
|
|
662
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
862
663
|
}')
|
|
863
664
|
|
|
864
|
-
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)
|
|
865
666
|
|
|
866
667
|
if [ -z "$RESP" ]; then
|
|
867
668
|
synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
|
|
868
|
-
echo '{}'
|
|
869
|
-
exit 0
|
|
669
|
+
echo '{}'; exit 0
|
|
870
670
|
fi
|
|
871
671
|
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
874
676
|
echo '{}'
|
|
875
|
-
exit 0
|
|
876
677
|
fi
|
|
877
|
-
|
|
878
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
879
678
|
exit 0
|
|
880
679
|
`;
|
|
881
|
-
|
|
680
|
+
CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
|
|
882
681
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
883
682
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
884
683
|
|
|
@@ -890,231 +689,79 @@ PAYLOAD=$(cat)
|
|
|
890
689
|
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
891
690
|
|
|
892
691
|
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
893
|
-
case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
|
|
894
|
-
|
|
895
|
-
TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
|
|
896
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
897
|
-
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
898
692
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
693
|
+
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
899
694
|
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
900
|
-
PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
|
|
901
695
|
|
|
902
|
-
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)
|
|
903
698
|
if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
904
699
|
|
|
905
|
-
|
|
906
|
-
synkro_log "editGuard checking: $
|
|
907
|
-
|
|
908
|
-
IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
|
|
909
|
-
case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
|
|
910
|
-
|
|
911
|
-
# Read file before edit for reconstruction
|
|
912
|
-
FILE_BEFORE=""
|
|
913
|
-
if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
|
|
914
|
-
FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
|
|
915
|
-
fi
|
|
916
|
-
|
|
917
|
-
# Reconstruct proposed content
|
|
918
|
-
case "$TOOL_NAME" in
|
|
919
|
-
Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
|
|
920
|
-
Edit|MultiEdit)
|
|
921
|
-
if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
|
|
922
|
-
PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
|
|
923
|
-
import os, json, sys
|
|
924
|
-
fb = os.environ.get("FILE_BEFORE_LITERAL", "")
|
|
925
|
-
ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
|
|
926
|
-
result = fb
|
|
927
|
-
if "old_string" in ti and "new_string" in ti:
|
|
928
|
-
if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
|
|
929
|
-
elif "edits" in ti and isinstance(ti["edits"], list):
|
|
930
|
-
for e in ti["edits"]:
|
|
931
|
-
old = e.get("old_string", "") if isinstance(e, dict) else ""
|
|
932
|
-
new = e.get("new_string", "") if isinstance(e, dict) else ""
|
|
933
|
-
if old: result = result.replace(old, new, 1)
|
|
934
|
-
sys.stdout.write(result)
|
|
935
|
-
' 2>/dev/null)
|
|
936
|
-
fi
|
|
937
|
-
if [ -z "$PROPOSED" ]; then
|
|
938
|
-
if [ "$TOOL_NAME" = "MultiEdit" ]; then
|
|
939
|
-
PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
|
|
940
|
-
else
|
|
941
|
-
PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
|
|
942
|
-
fi
|
|
943
|
-
fi ;;
|
|
944
|
-
NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
|
|
945
|
-
esac
|
|
946
|
-
if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
|
|
947
|
-
|
|
948
|
-
DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null)
|
|
949
|
-
[ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
|
|
950
|
-
|
|
951
|
-
# Extract user intent from transcript
|
|
952
|
-
USER_INTENT=""
|
|
953
|
-
RECENT_ACTIONS="[]"
|
|
954
|
-
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
955
|
-
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
956
|
-
USER_INTENT=$(tail -200 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "user") | .message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end' 2>/dev/null | tail -1 || echo "")
|
|
957
|
-
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 "[]")
|
|
958
|
-
fi
|
|
700
|
+
BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
|
|
701
|
+
synkro_log "editGuard checking: $BASENAME"
|
|
959
702
|
|
|
960
703
|
synkro_load_config
|
|
961
|
-
ROUTE=$(synkro_route)
|
|
962
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
963
|
-
|
|
964
704
|
if [ "$SYNKRO_SILENT" = "true" ]; then
|
|
965
|
-
|
|
966
|
-
exit 0
|
|
967
|
-
fi
|
|
968
|
-
|
|
969
|
-
if [ "$ROUTE" = "local" ]; then
|
|
970
|
-
# \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
|
|
971
|
-
GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
|
|
972
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
973
|
-
printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nProposed content (first 4000 chars):\\n%s\\nUser intent (last human message): %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$PROPOSED" | head -c 4000)" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
|
|
974
|
-
|
|
975
|
-
CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
|
|
976
|
-
if [ $? -ne 0 ]; then
|
|
977
|
-
jq -n --arg m "$TAG editGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
|
|
978
|
-
exit 0
|
|
979
|
-
fi
|
|
980
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
981
|
-
|
|
982
|
-
# Build edit content description and violated rules for full-content capture
|
|
983
|
-
EDIT_CONTENT="file=$FILE_PATH content=$(printf '%s' "$PROPOSED" | head -c 2000)"
|
|
984
|
-
VIOLATED_JSON="[]"
|
|
985
|
-
[ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
986
|
-
|
|
987
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
988
|
-
RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
|
|
989
|
-
if [ "$RULE_MODE" = "audit" ]; then
|
|
990
|
-
REASON="$TAG editGuard $FILE_SHORT \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
|
|
991
|
-
jq -n --arg m "$REASON" '{systemMessage: $m}'
|
|
992
|
-
synkro_dispatch_capture "edit" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
993
|
-
"$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
|
|
994
|
-
else
|
|
995
|
-
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."
|
|
996
|
-
jq -n --arg reason "$REASON" \\
|
|
997
|
-
'{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
|
|
998
|
-
synkro_dispatch_capture "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
999
|
-
"$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
|
|
1000
|
-
fi
|
|
1001
|
-
else
|
|
1002
|
-
jq -n --arg m "$TAG editGuard $FILE_SHORT \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
|
|
1003
|
-
synkro_dispatch_capture "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1004
|
-
"$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
|
|
1005
|
-
fi
|
|
1006
|
-
exit 0
|
|
705
|
+
echo '{}'; exit 0
|
|
1007
706
|
fi
|
|
1008
707
|
|
|
1009
|
-
# \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
1010
708
|
BODY=$(jq -n \\
|
|
1011
|
-
--arg hook_event "PreToolUse" \\
|
|
1012
|
-
--arg tool_name "$TOOL_NAME" \\
|
|
1013
|
-
--argjson tool_input "$TOOL_INPUT" \\
|
|
1014
709
|
--arg file_path "$FILE_PATH" \\
|
|
1015
|
-
--arg content "$
|
|
1016
|
-
--arg file_before "$FILE_BEFORE" \\
|
|
1017
|
-
--argjson diff "$DIFF_FIELD" \\
|
|
1018
|
-
--arg user_intent "$USER_INTENT" \\
|
|
1019
|
-
--argjson recent_actions "$RECENT_ACTIONS" \\
|
|
710
|
+
--arg content "$CONTENT" \\
|
|
1020
711
|
--arg session_id "$SESSION_ID" \\
|
|
1021
|
-
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
1022
712
|
--arg cwd "$CWD" \\
|
|
1023
713
|
--arg repo "$GIT_REPO" \\
|
|
1024
|
-
--arg permission_mode "$PERMISSION_MODE" \\
|
|
1025
|
-
--arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
|
|
1026
714
|
'{
|
|
1027
|
-
hook_event:
|
|
1028
|
-
tool_name:
|
|
1029
|
-
tool_input: $
|
|
715
|
+
hook_event: "PreToolUse",
|
|
716
|
+
tool_name: "Edit",
|
|
717
|
+
tool_input: {file_path: $file_path, content: $content},
|
|
1030
718
|
file_path: $file_path,
|
|
1031
719
|
content: $content,
|
|
1032
|
-
|
|
1033
|
-
diff: $diff,
|
|
1034
|
-
user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
|
|
1035
|
-
recent_actions: $recent_actions,
|
|
720
|
+
response_format: "cursor",
|
|
1036
721
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1037
|
-
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
1038
722
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1039
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1040
|
-
permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
|
|
1041
|
-
headless: ($headless_flag == "1")
|
|
723
|
+
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1042
724
|
}')
|
|
1043
725
|
|
|
1044
726
|
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
|
|
1045
727
|
|
|
1046
728
|
if [ -z "$RESP" ]; then
|
|
1047
|
-
synkro_log "editGuard $
|
|
1048
|
-
echo '{}'
|
|
1049
|
-
exit 0
|
|
1050
|
-
fi
|
|
1051
|
-
|
|
1052
|
-
if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
1053
|
-
synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
|
|
1054
|
-
echo '{}'
|
|
1055
|
-
exit 0
|
|
729
|
+
synkro_log "editGuard $BASENAME \u2192 error (timeout)"
|
|
730
|
+
echo '{}'; exit 0
|
|
1056
731
|
fi
|
|
1057
732
|
|
|
1058
|
-
|
|
1059
|
-
if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
|
|
1060
|
-
synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
|
|
733
|
+
if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
|
|
1061
734
|
echo "$RESP" | jq -c '.hook_response'
|
|
1062
735
|
else
|
|
1063
|
-
|
|
1064
|
-
if [ -n "$REASON" ]; then
|
|
1065
|
-
synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
|
|
1066
|
-
else
|
|
1067
|
-
synkro_log "editGuard $FILE_SHORT \u2192 pass"
|
|
1068
|
-
fi
|
|
1069
|
-
echo "$RESP" | jq -c '.hook_response // {}'
|
|
736
|
+
echo '{}'
|
|
1070
737
|
fi
|
|
1071
738
|
exit 0
|
|
1072
739
|
`;
|
|
1073
|
-
|
|
740
|
+
CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
|
|
1074
741
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1075
742
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1076
743
|
|
|
1077
744
|
JWT=$(synkro_load_jwt)
|
|
1078
745
|
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
1079
|
-
synkro_ensure_fresh_jwt
|
|
1080
746
|
|
|
1081
747
|
PAYLOAD=$(cat)
|
|
1082
748
|
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
1083
749
|
|
|
1084
|
-
|
|
1085
|
-
|
|
750
|
+
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
|
|
751
|
+
if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
1086
752
|
|
|
1087
|
-
|
|
1088
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.
|
|
1089
|
-
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
1090
|
-
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)
|
|
1091
755
|
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
756
|
+
BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
|
|
1092
757
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1098
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1099
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1
|
|
1100
|
-
) &
|
|
1101
|
-
fi
|
|
1102
|
-
|
|
1103
|
-
# Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
|
|
1104
|
-
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
|
|
1105
|
-
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
1106
|
-
|
|
1107
|
-
BASENAME=$(basename "$FILE_PATH")
|
|
1108
|
-
synkro_log "editScan: $BASENAME"
|
|
1109
|
-
|
|
1110
|
-
FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
|
|
1111
|
-
if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
|
|
1112
|
-
|
|
1113
|
-
DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null || echo "null")
|
|
1114
|
-
[ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
|
|
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)
|
|
1115
762
|
|
|
1116
763
|
DEPS_JSON="{}"
|
|
1117
|
-
_PKG_DIR
|
|
764
|
+
_PKG_DIR="\${CWD:-.}"
|
|
1118
765
|
while [ "$_PKG_DIR" != "/" ]; do
|
|
1119
766
|
if [ -f "$_PKG_DIR/package.json" ]; then
|
|
1120
767
|
DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
|
|
@@ -1123,778 +770,1961 @@ while [ "$_PKG_DIR" != "/" ]; do
|
|
|
1123
770
|
_PKG_DIR=$(dirname "$_PKG_DIR")
|
|
1124
771
|
done
|
|
1125
772
|
|
|
1126
|
-
|
|
1127
|
-
ROUTE=$(synkro_route)
|
|
1128
|
-
TAG=$(synkro_tag "$ROUTE")
|
|
1129
|
-
|
|
1130
|
-
if [ "$SYNKRO_SILENT" = "true" ]; then
|
|
1131
|
-
echo '{}'; exit 0
|
|
1132
|
-
fi
|
|
1133
|
-
|
|
1134
|
-
if [ "$ROUTE" = "local" ]; then
|
|
1135
|
-
# \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
|
|
1136
|
-
GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
|
|
1137
|
-
trap "rm -f \\"$GRADER_FILE\\"" EXIT
|
|
1138
|
-
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"
|
|
1139
|
-
|
|
1140
|
-
CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
|
|
1141
|
-
if [ $? -ne 0 ]; then
|
|
1142
|
-
echo '{}'; exit 0
|
|
1143
|
-
fi
|
|
1144
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
1145
|
-
|
|
1146
|
-
SCAN_CONTENT="file=$FILE_PATH"
|
|
1147
|
-
SCAN_VIOLATED="[]"
|
|
1148
|
-
[ -n "$LOCAL_RULE_ID" ] && SCAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
1149
|
-
|
|
1150
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
1151
|
-
RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
|
|
1152
|
-
if [ "$RULE_MODE" = "audit" ]; then
|
|
1153
|
-
REASON="$TAG editScan $BASENAME \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
|
|
1154
|
-
jq -n --arg m "$REASON" '{systemMessage: $m}'
|
|
1155
|
-
synkro_dispatch_capture "edit_scan" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1156
|
-
"$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
|
|
1157
|
-
else
|
|
1158
|
-
REASON="$TAG editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
|
|
1159
|
-
jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
|
|
1160
|
-
synkro_dispatch_capture "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1161
|
-
"$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
|
|
1162
|
-
fi
|
|
1163
|
-
else
|
|
1164
|
-
jq -n --arg m "$TAG editScan $BASENAME \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
|
|
1165
|
-
synkro_dispatch_capture "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
|
|
1166
|
-
"$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
|
|
1167
|
-
fi
|
|
1168
|
-
exit 0
|
|
1169
|
-
fi
|
|
1170
|
-
|
|
1171
|
-
# \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
|
|
1172
|
-
BODY=$(jq -n \\
|
|
1173
|
-
--arg hook_event "PostToolUse" \\
|
|
1174
|
-
--arg tool_name "$TOOL_NAME" \\
|
|
1175
|
-
--argjson tool_input "$TOOL_INPUT" \\
|
|
1176
|
-
--arg file_path "$FILE_PATH" \\
|
|
1177
|
-
--arg content "$FILE_CONTENT" \\
|
|
1178
|
-
--argjson diff "$DIFF_FIELD" \\
|
|
1179
|
-
--argjson dependencies "$DEPS_JSON" \\
|
|
1180
|
-
--arg session_id "$SESSION_ID" \\
|
|
1181
|
-
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
1182
|
-
--arg cwd "$CWD" \\
|
|
1183
|
-
--arg repo "$GIT_REPO" \\
|
|
1184
|
-
'{
|
|
1185
|
-
hook_event: $hook_event,
|
|
1186
|
-
tool_name: $tool_name,
|
|
1187
|
-
tool_input: $tool_input,
|
|
1188
|
-
file_path: $file_path,
|
|
1189
|
-
content: $content,
|
|
1190
|
-
diff: $diff,
|
|
1191
|
-
dependencies: $dependencies,
|
|
1192
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1193
|
-
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
1194
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1195
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1196
|
-
}')
|
|
1197
|
-
|
|
1198
|
-
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
|
|
773
|
+
synkro_log "editScan $BASENAME"
|
|
1199
774
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
1205
789
|
|
|
1206
|
-
|
|
1207
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
1208
|
-
else
|
|
1209
|
-
echo '{}'
|
|
1210
|
-
fi
|
|
790
|
+
echo '{}'
|
|
1211
791
|
exit 0
|
|
1212
792
|
`;
|
|
1213
|
-
|
|
793
|
+
CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
|
|
1214
794
|
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1215
795
|
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1216
796
|
|
|
1217
797
|
JWT=$(synkro_load_jwt)
|
|
1218
798
|
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
1219
|
-
synkro_ensure_fresh_jwt
|
|
1220
799
|
|
|
1221
800
|
PAYLOAD=$(cat)
|
|
1222
|
-
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
1223
|
-
|
|
1224
801
|
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
# ExitPlanMode's tool_input contains {allowedPrompts:[...]}, not plan content.
|
|
1228
|
-
# Read from the most recently modified plan file instead.
|
|
1229
|
-
PLANS_DIR="$HOME/.claude/plans"
|
|
1230
|
-
PLAN_FILE=$(ls -t "$PLANS_DIR"/*.md 2>/dev/null | head -1)
|
|
1231
|
-
if [ -z "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ]; then echo '{}'; exit 0; fi
|
|
1232
|
-
PLAN=$(cat "$PLAN_FILE" 2>/dev/null)
|
|
1233
|
-
if [ -z "$PLAN" ] || [ \${#PLAN} -lt 20 ]; then echo '{}'; exit 0; fi
|
|
1234
|
-
|
|
1235
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
1236
|
-
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
1237
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
1238
|
-
|
|
1239
|
-
PLAN_SHORT=$(printf '%s' "$PLAN" | head -c 80)
|
|
1240
|
-
synkro_log "planReview checking: $PLAN_SHORT..."
|
|
1241
|
-
|
|
1242
|
-
# Write review verdict into the plan file so it survives ExitPlanMode rejection
|
|
1243
|
-
append_review_to_plan() {
|
|
1244
|
-
local verdict="$1"
|
|
1245
|
-
local tmp="\${PLAN_FILE}.synkro.tmp"
|
|
1246
|
-
sed '/^<!-- synkro-plan-review -->$/,/^<!-- \\/synkro-plan-review -->$/d' "$PLAN_FILE" | sed -e :a -e '/^\\n*$/{$d;N;ba' -e '}' > "$tmp" 2>/dev/null
|
|
1247
|
-
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"
|
|
1248
|
-
mv "$tmp" "$PLAN_FILE" 2>/dev/null
|
|
1249
|
-
}
|
|
802
|
+
case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
|
|
1250
803
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
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)
|
|
1254
806
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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)
|
|
1258
812
|
fi
|
|
1259
813
|
|
|
1260
|
-
if [ "$
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
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"
|
|
1264
|
-
|
|
1265
|
-
CC_RESP=$(synkro_local_grade plan < "$GRADER_FILE" 2>&1)
|
|
1266
|
-
if [ $? -ne 0 ]; then
|
|
1267
|
-
echo '{}'; exit 0
|
|
1268
|
-
fi
|
|
1269
|
-
synkro_parse_local_verdict "$CC_RESP"
|
|
1270
|
-
|
|
1271
|
-
PLAN_CONTENT=$(printf '%s' "$PLAN" | head -c 2000)
|
|
1272
|
-
PLAN_VIOLATED="[]"
|
|
1273
|
-
[ -n "$LOCAL_RULE_ID" ] && PLAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
|
|
1274
|
-
|
|
1275
|
-
if [ "$LOCAL_OK" = "false" ]; then
|
|
1276
|
-
VCOUNT=$(printf '%s' "$CC_RESP" | grep -c '<violation>' 2>/dev/null || echo "0")
|
|
1277
|
-
[ "$VCOUNT" = "0" ] && VCOUNT="1"
|
|
1278
|
-
REVIEW_MSG="\${VCOUNT} rule(s) relevant\${LOCAL_RULE_ID:+ (first: $LOCAL_RULE_ID)}: \${LOCAL_REASON:-check org rules during implementation}"
|
|
1279
|
-
append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REVIEW_MSG"
|
|
1280
|
-
MSG="$TAG planReview \u2192 $REVIEW_MSG"
|
|
1281
|
-
jq -n --arg m "$MSG" '{systemMessage: $m}'
|
|
1282
|
-
synkro_dispatch_capture "plan_review" "advisory" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
|
|
1283
|
-
"$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"
|
|
1284
817
|
else
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
synkro_dispatch_capture "plan_review" "clean" "audit" "\${LOCAL_CAT:-general}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
|
|
1289
|
-
"$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
|
|
1290
821
|
fi
|
|
1291
|
-
exit 0
|
|
1292
|
-
fi
|
|
1293
|
-
|
|
1294
|
-
# \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
1295
|
-
BODY=$(jq -n \\
|
|
1296
|
-
--arg hook_event "PreToolUse" \\
|
|
1297
|
-
--arg tool_name "ExitPlanMode" \\
|
|
1298
|
-
--arg plan "$(printf '%s' "$PLAN" | head -c 16000)" \\
|
|
1299
|
-
--arg session_id "$SESSION_ID" \\
|
|
1300
|
-
--arg cwd "$CWD" \\
|
|
1301
|
-
--arg repo "$GIT_REPO" \\
|
|
1302
|
-
'{
|
|
1303
|
-
hook_event: $hook_event,
|
|
1304
|
-
tool_name: $tool_name,
|
|
1305
|
-
tool_input: {plan: $plan},
|
|
1306
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1307
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1308
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1309
|
-
}')
|
|
1310
|
-
|
|
1311
|
-
RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
|
|
1312
|
-
|
|
1313
|
-
if [ -z "$RESP" ]; then
|
|
1314
|
-
synkro_log "planReview \u2192 error (timeout)"
|
|
1315
|
-
echo '{}'
|
|
1316
|
-
exit 0
|
|
1317
822
|
fi
|
|
1318
823
|
|
|
1319
|
-
if
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
|
1322
834
|
fi
|
|
1323
835
|
|
|
1324
|
-
|
|
1325
|
-
HR=$(echo "$RESP" | jq -c '.hook_response')
|
|
1326
|
-
if echo "$HR" | jq -e '.hookSpecificOutput.permissionDecision' >/dev/null 2>&1; then
|
|
1327
|
-
REASON=$(echo "$HR" | jq -r '.hookSpecificOutput.permissionDecisionReason // "check org rules"' 2>/dev/null)
|
|
1328
|
-
append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REASON"
|
|
1329
|
-
jq -n --arg m "$TAG planReview \u2192 advisory: $REASON" '{systemMessage: $m}'
|
|
1330
|
-
else
|
|
1331
|
-
CLOUD_MSG=$(echo "$HR" | jq -r '.systemMessage // empty' 2>/dev/null)
|
|
1332
|
-
[ -n "$CLOUD_MSG" ] && append_review_to_plan "\u2705 $CLOUD_MSG"
|
|
1333
|
-
echo "$HR"
|
|
1334
|
-
fi
|
|
836
|
+
echo '{}'
|
|
1335
837
|
exit 0
|
|
1336
838
|
`;
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
839
|
+
}
|
|
840
|
+
});
|
|
1340
841
|
|
|
1341
|
-
|
|
1342
|
-
|
|
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';
|
|
1343
853
|
|
|
1344
|
-
|
|
1345
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
1346
|
-
if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
|
|
854
|
+
// \u2500\u2500\u2500 Config \u2500\u2500\u2500
|
|
1347
855
|
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
856
|
+
const HOME = homedir();
|
|
857
|
+
const CONFIG_PATH = join(HOME, '.synkro', 'config.env');
|
|
1351
858
|
|
|
1352
|
-
|
|
1353
|
-
if
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
--arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
|
|
1372
|
-
--arg cc_model "\${CC_MODEL:-}" \\
|
|
1373
|
-
--arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
|
|
1374
|
-
--argjson cc_usage "$CC_USAGE" \\
|
|
1375
|
-
'{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
|
|
1376
|
-
+ (if $repo != "" then {repo:$repo} else {} end)
|
|
1377
|
-
+ (if $session_id != "" then {session_id:$session_id} else {} end)
|
|
1378
|
-
+ (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
|
|
1379
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1380
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1381
|
-
-d "$BODY" --max-time 2 >/dev/null 2>&1
|
|
1382
|
-
fi
|
|
1383
|
-
) &
|
|
1384
|
-
fi
|
|
859
|
+
// Load config.env into process.env
|
|
860
|
+
if (existsSync(CONFIG_PATH)) {
|
|
861
|
+
try {
|
|
862
|
+
const lines = readFileSync(CONFIG_PATH, 'utf-8').split('\\n');
|
|
863
|
+
for (const line of lines) {
|
|
864
|
+
const trimmed = line.trim();
|
|
865
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
866
|
+
const eqIdx = trimmed.indexOf('=');
|
|
867
|
+
if (eqIdx < 1) continue;
|
|
868
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
869
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
870
|
+
// Strip surrounding quotes
|
|
871
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
872
|
+
val = val.slice(1, -1);
|
|
873
|
+
}
|
|
874
|
+
process.env[key] = val;
|
|
875
|
+
}
|
|
876
|
+
} catch {}
|
|
877
|
+
}
|
|
1385
878
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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');
|
|
1389
882
|
|
|
1390
|
-
|
|
883
|
+
// \u2500\u2500\u2500 Logging \u2500\u2500\u2500
|
|
1391
884
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
|
|
885
|
+
export function log(msg: string): void {
|
|
886
|
+
process.stderr.write('[synkro] ' + msg + '\\n');
|
|
887
|
+
}
|
|
1396
888
|
|
|
1397
|
-
|
|
889
|
+
// \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
|
|
1398
890
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
if
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
+
}
|
|
1406
900
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
.
|
|
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
|
+
}
|
|
1413
914
|
|
|
1414
|
-
|
|
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;
|
|
1415
920
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
+
}
|
|
1422
944
|
|
|
1423
|
-
|
|
1424
|
-
if
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
-H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
|
|
1429
|
-
if [ -n "$RESP" ]; then
|
|
1430
|
-
SYNKRO_SILENT=$(echo "$RESP" | jq -r '.silent_mode // false' 2>/dev/null)
|
|
1431
|
-
SYNKRO_POLICY_NAME=$(echo "$RESP" | jq -r '.active_policy_name // empty' 2>/dev/null)
|
|
1432
|
-
fi
|
|
1433
|
-
fi
|
|
945
|
+
export async function ensureFreshJwt(jwt: string): Promise<string> {
|
|
946
|
+
if (!jwt) return jwt;
|
|
947
|
+
const exp = decodeJwtExp(jwt);
|
|
948
|
+
const now = Math.floor(Date.now() / 1000);
|
|
949
|
+
if (exp - now >= 60) return jwt;
|
|
1434
950
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
+
}
|
|
1443
966
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
+
}
|
|
1448
981
|
|
|
1449
|
-
|
|
1450
|
-
if [ -n "$RESP" ]; then
|
|
1451
|
-
OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
|
|
1452
|
-
fi
|
|
982
|
+
// \u2500\u2500\u2500 Repo Detection \u2500\u2500\u2500
|
|
1453
983
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
CC_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
|
|
1467
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1468
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
984
|
+
export function detectRepo(cwd: string): string {
|
|
985
|
+
try {
|
|
986
|
+
const url = execSync('git remote get-url origin', { cwd, timeout: 3000, encoding: 'utf-8' }).trim();
|
|
987
|
+
if (!url) return '';
|
|
988
|
+
return url
|
|
989
|
+
.replace(/^git@[^:]+:/, '')
|
|
990
|
+
.replace(/^https?:\\/\\/[^/]+\\//, '')
|
|
991
|
+
.replace(/\\.git$/, '');
|
|
992
|
+
} catch {
|
|
993
|
+
return '';
|
|
994
|
+
}
|
|
995
|
+
}
|
|
1469
996
|
|
|
1470
|
-
|
|
1471
|
-
if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
|
|
997
|
+
// \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
|
|
1472
998
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
+
}
|
|
1476
1012
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1013
|
+
export async function cweChannelUp(): Promise<boolean> {
|
|
1014
|
+
return channelUp(8930);
|
|
1015
|
+
}
|
|
1480
1016
|
|
|
1481
|
-
|
|
1482
|
-
IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
|
|
1483
|
-
CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
1484
|
-
CMD_HASH=""
|
|
1485
|
-
if [ -n "$CMD" ]; then
|
|
1486
|
-
CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
|
|
1487
|
-
fi
|
|
1017
|
+
// \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
|
|
1488
1018
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1019
|
+
export interface Rule {
|
|
1020
|
+
rule_id: string;
|
|
1021
|
+
text: string;
|
|
1022
|
+
severity: string;
|
|
1023
|
+
category: string;
|
|
1024
|
+
mode: string;
|
|
1025
|
+
}
|
|
1492
1026
|
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
else
|
|
1501
|
-
if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
|
|
1502
|
-
synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
|
|
1503
|
-
fi
|
|
1504
|
-
fi
|
|
1505
|
-
fi
|
|
1027
|
+
export interface HookConfig {
|
|
1028
|
+
captureDepth: string;
|
|
1029
|
+
tier: string;
|
|
1030
|
+
silent: boolean;
|
|
1031
|
+
policyName: string;
|
|
1032
|
+
rules: Rule[];
|
|
1033
|
+
}
|
|
1506
1034
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
+
}
|
|
1510
1068
|
|
|
1511
|
-
|
|
1512
|
-
exit 0
|
|
1513
|
-
`;
|
|
1514
|
-
CC_CVE_SCAN_SCRIPT = `#!/bin/bash
|
|
1515
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1516
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1069
|
+
// \u2500\u2500\u2500 Routing \u2500\u2500\u2500
|
|
1517
1070
|
|
|
1518
|
-
|
|
1519
|
-
if
|
|
1520
|
-
|
|
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
|
+
}
|
|
1521
1076
|
|
|
1522
|
-
|
|
1523
|
-
if
|
|
1077
|
+
export async function cweRoute(config: HookConfig): Promise<'local' | 'cloud'> {
|
|
1078
|
+
if (config.captureDepth === 'local_only') return 'local';
|
|
1079
|
+
if (await cweChannelUp()) return 'local';
|
|
1080
|
+
return 'cloud';
|
|
1081
|
+
}
|
|
1524
1082
|
|
|
1525
|
-
|
|
1526
|
-
case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
|
|
1083
|
+
// \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
|
|
1527
1084
|
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
+
}
|
|
1530
1090
|
|
|
1531
|
-
|
|
1532
|
-
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
|
|
1091
|
+
// \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
|
|
1533
1092
|
|
|
1534
|
-
|
|
1535
|
-
|
|
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[];
|
|
1536
1098
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
done
|
|
1099
|
+
if (cliBin && existsSync(cliBin)) {
|
|
1100
|
+
// Use the CLI binary directly with bun/node
|
|
1101
|
+
cmd = 'node';
|
|
1102
|
+
args = [cliBin, 'grade', surface];
|
|
1103
|
+
} else {
|
|
1104
|
+
cmd = 'synkro';
|
|
1105
|
+
args = ['grade', surface];
|
|
1106
|
+
}
|
|
1546
1107
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1108
|
+
const child = spawn(cmd, args, {
|
|
1109
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1110
|
+
env: { ...process.env, ...envOverride },
|
|
1111
|
+
});
|
|
1550
1112
|
|
|
1551
|
-
|
|
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
|
+
}
|
|
1552
1133
|
|
|
1553
|
-
|
|
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
|
+
}
|
|
1554
1138
|
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
+
}
|
|
1557
1143
|
|
|
1558
|
-
|
|
1559
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1560
|
-
-d "$BODY" --max-time 6 2>/dev/null || echo "")
|
|
1144
|
+
// \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
|
|
1561
1145
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1146
|
+
export interface Verdict {
|
|
1147
|
+
ok: boolean;
|
|
1148
|
+
reason: string;
|
|
1149
|
+
ruleId: string;
|
|
1150
|
+
ruleMode: string;
|
|
1151
|
+
severity: string;
|
|
1152
|
+
category: string;
|
|
1153
|
+
}
|
|
1565
1154
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL}, \${CVE_CRIT} critical/high (\${CRIT_PKGS})" '{systemMessage: $m}'
|
|
1576
|
-
else
|
|
1577
|
-
[ "$ALL_TOTAL" -gt 3 ] && ALL_PKGS="\${ALL_PKGS}, ..."
|
|
1578
|
-
jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL} (\${ALL_PKGS})" '{systemMessage: $m}'
|
|
1579
|
-
fi
|
|
1580
|
-
else
|
|
1581
|
-
jq -n --arg m "[synkro:\${ROUTE}:cveScan] clean" '{systemMessage: $m}'
|
|
1582
|
-
fi
|
|
1583
|
-
exit 0
|
|
1584
|
-
`;
|
|
1585
|
-
CC_TRANSCRIPT_SYNC_SCRIPT = `#!/bin/bash
|
|
1586
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1587
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1155
|
+
export function parseVerdict(resp: string): Verdict {
|
|
1156
|
+
const verdict: Verdict = {
|
|
1157
|
+
ok: true,
|
|
1158
|
+
reason: '',
|
|
1159
|
+
ruleId: '',
|
|
1160
|
+
ruleMode: '',
|
|
1161
|
+
severity: 'low',
|
|
1162
|
+
category: 'clean',
|
|
1163
|
+
};
|
|
1588
1164
|
|
|
1589
|
-
|
|
1590
|
-
|
|
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];
|
|
1591
1170
|
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
1595
|
-
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
1171
|
+
const okMatch = inner.match(/<ok>(.*?)<\\/ok>/);
|
|
1172
|
+
if (okMatch) verdict.ok = okMatch[1].trim() !== 'false';
|
|
1596
1173
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
fi
|
|
1174
|
+
const reasonMatch = inner.match(/<reason>(.*?)<\\/reason>/) || inner.match(/<reasoning>(.*?)<\\/reasoning>/);
|
|
1175
|
+
if (reasonMatch) verdict.reason = reasonMatch[1].trim();
|
|
1600
1176
|
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
_TI=$(echo "$_LAST_ASST" | jq -r '.message.usage.input_tokens // 0' 2>/dev/null)
|
|
1606
|
-
_TO=$(echo "$_LAST_ASST" | jq -r '.message.usage.output_tokens // 0' 2>/dev/null)
|
|
1607
|
-
_TCW=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_creation_input_tokens // 0' 2>/dev/null)
|
|
1608
|
-
_TCR=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_read_input_tokens // 0' 2>/dev/null)
|
|
1609
|
-
if [ "\${_TI:-0}" != "0" ] || [ "\${_TO:-0}" != "0" ]; then
|
|
1610
|
-
(
|
|
1611
|
-
_USAGE="{\\"input_tokens\\":$_TI,\\"output_tokens\\":$_TO,\\"cache_creation_input_tokens\\":$_TCW,\\"cache_read_input_tokens\\":$_TCR}"
|
|
1612
|
-
_BODY=$(jq -n \\
|
|
1613
|
-
--arg event_id "usage_$(date +%s)_$$" \\
|
|
1614
|
-
--arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
|
|
1615
|
-
--arg model "\${_CC_MODEL:-claude-sonnet-4-6}" \\
|
|
1616
|
-
--arg cc_model "\${_CC_MODEL:-}" \\
|
|
1617
|
-
--arg session_id "$SESSION_ID" \\
|
|
1618
|
-
--argjson cc_usage "$_USAGE" \\
|
|
1619
|
-
'{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}
|
|
1620
|
-
+ (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
|
|
1621
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1622
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1623
|
-
-d "$_BODY" --max-time 2 >/dev/null 2>&1
|
|
1624
|
-
) &
|
|
1625
|
-
fi
|
|
1626
|
-
fi
|
|
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>/);
|
|
1627
1181
|
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
+
}
|
|
1630
1201
|
|
|
1631
|
-
|
|
1632
|
-
if
|
|
1202
|
+
if (ruleModeMatch) verdict.ruleMode = ruleModeMatch[1].trim();
|
|
1203
|
+
if (sevMatch) verdict.severity = sevMatch[1].trim();
|
|
1204
|
+
verdict.severity = verdict.severity || 'high';
|
|
1633
1205
|
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
CAPTURE_DEPTH=$(echo "$CONFIG_RESP" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
|
|
1637
|
-
if [ "$CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
|
|
1206
|
+
const catMatch = inner.match(/<category>(.*?)<\\/category>/);
|
|
1207
|
+
verdict.category = catMatch ? catMatch[1].trim() : 'uncategorized';
|
|
1638
1208
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
+
}
|
|
1644
1215
|
|
|
1645
|
-
|
|
1646
|
-
|
|
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;
|
|
1647
1260
|
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
+
}
|
|
1651
1269
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
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),
|
|
1660
|
-
model: ($line.message.model // null),
|
|
1661
|
-
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)
|
|
1662
|
-
}
|
|
1663
|
-
else empty end
|
|
1664
|
-
' 2>/dev/null | jq -s '.' 2>/dev/null)
|
|
1665
|
-
|
|
1666
|
-
if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
|
|
1667
|
-
printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
|
|
1668
|
-
echo '{}'; exit 0
|
|
1669
|
-
fi
|
|
1270
|
+
fetch(GATEWAY_URL + '/api/v1/hook/capture', {
|
|
1271
|
+
method: 'POST',
|
|
1272
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
1273
|
+
body: JSON.stringify(body),
|
|
1274
|
+
signal: AbortSignal.timeout(3000),
|
|
1275
|
+
}).catch(() => {});
|
|
1276
|
+
}
|
|
1670
1277
|
|
|
1671
|
-
|
|
1672
|
-
'{repo: $repo, sessions: [{cc_session_id: $sid, messages: $messages}]}')
|
|
1278
|
+
// \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
|
|
1673
1279
|
|
|
1674
|
-
(
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
)
|
|
1679
|
-
|
|
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
|
+
}
|
|
1680
1286
|
|
|
1681
|
-
|
|
1682
|
-
echo '{}'; exit 0
|
|
1683
|
-
`;
|
|
1684
|
-
CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
|
|
1685
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1686
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
1287
|
+
// \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
|
|
1687
1288
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
+
}
|
|
1691
1331
|
|
|
1692
|
-
|
|
1693
|
-
if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
|
|
1332
|
+
// \u2500\u2500\u2500 HTTP with Retry \u2500\u2500\u2500
|
|
1694
1333
|
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
+
}
|
|
1697
1347
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
1348
|
+
let data: any;
|
|
1349
|
+
try { data = await resp.json(); } catch { return null; }
|
|
1701
1350
|
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
+
}
|
|
1704
1366
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
echo '{}'; exit 0
|
|
1708
|
-
fi
|
|
1367
|
+
return data;
|
|
1368
|
+
}
|
|
1709
1369
|
|
|
1710
|
-
|
|
1711
|
-
--arg cmd "$COMMAND" \\
|
|
1712
|
-
--arg session_id "$SESSION_ID" \\
|
|
1713
|
-
--arg cwd "$CWD" \\
|
|
1714
|
-
--arg repo "$GIT_REPO" \\
|
|
1715
|
-
'{
|
|
1716
|
-
hook_event: "PreToolUse",
|
|
1717
|
-
tool_name: "Bash",
|
|
1718
|
-
tool_input: {command: $cmd},
|
|
1719
|
-
response_format: "cursor",
|
|
1720
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1721
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1722
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1723
|
-
}')
|
|
1370
|
+
// \u2500\u2500\u2500 Read Stdin \u2500\u2500\u2500
|
|
1724
1371
|
|
|
1725
|
-
|
|
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
|
+
}
|
|
1726
1379
|
|
|
1727
|
-
|
|
1728
|
-
synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
|
|
1729
|
-
echo '{}'; exit 0
|
|
1730
|
-
fi
|
|
1380
|
+
// \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
|
|
1731
1381
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
+
}
|
|
1739
1591
|
`;
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1592
|
+
EDIT_PRECHECK_TS = `#!/usr/bin/env bun
|
|
1593
|
+
import {
|
|
1594
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
1595
|
+
parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
|
|
1596
|
+
readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
|
|
1597
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
1598
|
+
type HookConfig, type Rule,
|
|
1599
|
+
} from './_synkro-common.ts';
|
|
1600
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
1601
|
+
import { basename, dirname, join } from 'node:path';
|
|
1602
|
+
|
|
1603
|
+
async function main() {
|
|
1604
|
+
try {
|
|
1605
|
+
const input = await readStdin();
|
|
1606
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1607
|
+
|
|
1608
|
+
const payload = JSON.parse(input);
|
|
1609
|
+
const toolName = payload.tool_name || '';
|
|
1610
|
+
if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1611
|
+
outputEmpty();
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const toolInput = payload.tool_input || {};
|
|
1616
|
+
const sessionId = payload.session_id || '';
|
|
1617
|
+
const toolUseId = payload.tool_use_id || '';
|
|
1618
|
+
const cwd = payload.cwd || '';
|
|
1619
|
+
const permissionMode = payload.permission_mode || '';
|
|
1620
|
+
const transcriptPath = payload.transcript_path || '';
|
|
1621
|
+
|
|
1622
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1623
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1624
|
+
|
|
1625
|
+
const fileShort = basename(filePath);
|
|
1626
|
+
log('editGuard checking: ' + fileShort);
|
|
1627
|
+
|
|
1628
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1629
|
+
|
|
1630
|
+
let jwt = loadJwt();
|
|
1631
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1632
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1633
|
+
|
|
1634
|
+
// Reconstruct proposed content
|
|
1635
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1636
|
+
if (!proposed) { outputEmpty(); return; }
|
|
1637
|
+
|
|
1638
|
+
// Build diff field
|
|
1639
|
+
let diffField: any = null;
|
|
1640
|
+
if (toolInput.old_string != null || toolInput.new_string != null || toolInput.edits != null) {
|
|
1641
|
+
diffField = {};
|
|
1642
|
+
if (toolInput.old_string != null) diffField.old_string = toolInput.old_string;
|
|
1643
|
+
if (toolInput.new_string != null) diffField.new_string = toolInput.new_string;
|
|
1644
|
+
if (toolInput.edits != null) diffField.edits = toolInput.edits;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Read file before edit for cloud payload
|
|
1648
|
+
let fileBefore = '';
|
|
1649
|
+
if (toolName !== 'Write' && filePath && existsSync(filePath)) {
|
|
1650
|
+
try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Extract transcript context
|
|
1654
|
+
const transcript = extractTranscript(transcriptPath);
|
|
1655
|
+
const lastPrompt = readLastPrompt();
|
|
1656
|
+
|
|
1657
|
+
// Load config and decide route
|
|
1658
|
+
const config = await loadConfig(jwt);
|
|
1659
|
+
const rt = await route(config);
|
|
1660
|
+
const tagStr = tag(rt, config);
|
|
1661
|
+
|
|
1662
|
+
if (config.silent) {
|
|
1663
|
+
outputJson({ systemMessage: tagStr + ' editGuard \\u2192 skipped (silent mode)' });
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (rt === 'local') {
|
|
1668
|
+
// \u2500\u2500\u2500 Local grading: org rules ONLY (channel 1, port 8929) \u2500\u2500\u2500
|
|
1669
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1670
|
+
const graderPrompt = [
|
|
1671
|
+
'Working directory: ' + (cwd || '.'),
|
|
1672
|
+
'Repo: ' + (gitRepo || 'unknown'),
|
|
1673
|
+
'File: ' + filePath,
|
|
1674
|
+
'Proposed content (first 4000 chars):',
|
|
1675
|
+
proposedShort,
|
|
1676
|
+
'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
|
|
1677
|
+
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
1678
|
+
'Org rules: ' + JSON.stringify(config.rules),
|
|
1679
|
+
].join('\\n');
|
|
1680
|
+
|
|
1681
|
+
let gradeResp: string;
|
|
1682
|
+
try {
|
|
1683
|
+
gradeResp = await localGrade('edit', graderPrompt);
|
|
1684
|
+
} catch {
|
|
1685
|
+
outputEmpty();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const verdict = parseVerdict(gradeResp);
|
|
1690
|
+
const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
|
|
1691
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
1692
|
+
|
|
1693
|
+
if (!verdict.ok) {
|
|
1694
|
+
const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
|
|
1695
|
+
const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
|
|
1696
|
+
|
|
1697
|
+
if (mode !== 'audit') {
|
|
1698
|
+
const denyReason = 'Guard: ' + guardReason + '\\nFix all issues before retrying.';
|
|
1699
|
+
dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
1700
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
1701
|
+
command: editContent, reasoning: guardReason,
|
|
1702
|
+
rulesChecked: config.rules, violatedRules,
|
|
1703
|
+
ccModel: transcript.ccModel,
|
|
1704
|
+
});
|
|
1705
|
+
outputJson({
|
|
1706
|
+
systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 blocked: ' + guardReason,
|
|
1707
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: denyReason },
|
|
1708
|
+
});
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Audit mode \u2014 warn but allow
|
|
1713
|
+
dispatchCapture(jwt, 'edit', 'warning', verdict.severity || 'medium', verdict.category || 'security',
|
|
1714
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
1715
|
+
command: editContent, reasoning: guardReason,
|
|
1716
|
+
rulesChecked: config.rules, violatedRules,
|
|
1717
|
+
ccModel: transcript.ccModel,
|
|
1718
|
+
});
|
|
1719
|
+
outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 warning: ' + guardReason });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Clean
|
|
1724
|
+
dispatchCapture(jwt, 'edit', 'pass', 'audit', verdict.category || 'trivial_edit',
|
|
1725
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
1726
|
+
command: editContent, reasoning: verdict.reason || 'no policy violations detected',
|
|
1727
|
+
rulesChecked: config.rules, violatedRules: [],
|
|
1728
|
+
ccModel: transcript.ccModel,
|
|
1729
|
+
});
|
|
1730
|
+
outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
1735
|
+
const deps = findNearestDeps(filePath);
|
|
1736
|
+
const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
|
|
1737
|
+
|| process.env.SYNKRO_HEADLESS === '1';
|
|
1738
|
+
|
|
1739
|
+
const body = {
|
|
1740
|
+
hook_event: 'PreToolUse',
|
|
1741
|
+
tool_name: toolName,
|
|
1742
|
+
tool_input: toolInput,
|
|
1743
|
+
file_path: filePath,
|
|
1744
|
+
content: proposed,
|
|
1745
|
+
file_before: fileBefore || null,
|
|
1746
|
+
diff: diffField,
|
|
1747
|
+
dependencies: deps,
|
|
1748
|
+
user_intent: transcript.userIntent || null,
|
|
1749
|
+
last_user_message: lastPrompt || null,
|
|
1750
|
+
recent_user_messages: transcript.recentUserMessages,
|
|
1751
|
+
recent_messages: transcript.recentMessages,
|
|
1752
|
+
recent_actions: transcript.recentActions,
|
|
1753
|
+
session_id: sessionId || null,
|
|
1754
|
+
tool_use_id: toolUseId || null,
|
|
1755
|
+
cwd: cwd || null,
|
|
1756
|
+
repo: gitRepo || null,
|
|
1757
|
+
permission_mode: permissionMode || null,
|
|
1758
|
+
headless: isHeadless,
|
|
1759
|
+
};
|
|
1760
|
+
|
|
1761
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
|
|
1762
|
+
|
|
1763
|
+
if (!resp) {
|
|
1764
|
+
log('editGuard ' + fileShort + ' \\u2192 error (timeout)');
|
|
1765
|
+
outputEmpty();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
if (!resp.hook_response || typeof resp.hook_response !== 'object') {
|
|
1770
|
+
log('editGuard ' + fileShort + ' \\u2192 pass (no hook_response)');
|
|
1771
|
+
outputEmpty();
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const hookResp = resp.hook_response;
|
|
1776
|
+
const decision = hookResp?.hookSpecificOutput?.permissionDecision;
|
|
1777
|
+
|
|
1778
|
+
if (decision === 'deny' || decision === 'ask') {
|
|
1779
|
+
log('editGuard ' + fileShort + ' \\u2192 BLOCKED');
|
|
1780
|
+
// Strip permissionDecision \u2014 we use systemMessage only
|
|
1781
|
+
const cleaned = { ...hookResp };
|
|
1782
|
+
if (cleaned.hookSpecificOutput) {
|
|
1783
|
+
cleaned.hookSpecificOutput = { ...cleaned.hookSpecificOutput };
|
|
1784
|
+
delete cleaned.hookSpecificOutput.permissionDecision;
|
|
1785
|
+
delete cleaned.hookSpecificOutput.permissionDecisionReason;
|
|
1786
|
+
}
|
|
1787
|
+
outputJson(cleaned);
|
|
1788
|
+
} else {
|
|
1789
|
+
const reason = hookResp.reason || '';
|
|
1790
|
+
log('editGuard ' + fileShort + ' \\u2192 pass' + (reason ? ': ' + reason : ''));
|
|
1791
|
+
outputJson(hookResp);
|
|
1792
|
+
}
|
|
1793
|
+
} catch (err) {
|
|
1794
|
+
process.stderr.write('[synkro] editGuard error: ' + String(err) + '\\n');
|
|
1795
|
+
outputEmpty();
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
main();
|
|
1800
|
+
`;
|
|
1801
|
+
CWE_PRECHECK_TS = `#!/usr/bin/env bun
|
|
1802
|
+
import {
|
|
1803
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
|
|
1804
|
+
localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
|
|
1805
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
1806
|
+
} from './_synkro-common.ts';
|
|
1807
|
+
import { basename, extname } from 'node:path';
|
|
1808
|
+
|
|
1809
|
+
async function main() {
|
|
1810
|
+
try {
|
|
1811
|
+
const input = await readStdin();
|
|
1812
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1813
|
+
|
|
1814
|
+
const payload = JSON.parse(input);
|
|
1815
|
+
const toolName = payload.tool_name || '';
|
|
1816
|
+
if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1817
|
+
outputEmpty();
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const toolInput = payload.tool_input || {};
|
|
1822
|
+
const sessionId = payload.session_id || '';
|
|
1823
|
+
const cwd = payload.cwd || '';
|
|
1824
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
1825
|
+
|
|
1826
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1827
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1828
|
+
|
|
1829
|
+
const fileShort = basename(filePath);
|
|
1830
|
+
const fileExt = extname(filePath); // e.g. ".ts"
|
|
1831
|
+
|
|
1832
|
+
let jwt = loadJwt();
|
|
1833
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1834
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1835
|
+
|
|
1836
|
+
// Reconstruct proposed content
|
|
1837
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1838
|
+
if (!proposed) { outputEmpty(); return; }
|
|
1839
|
+
|
|
1840
|
+
const config = await loadConfig(jwt);
|
|
1841
|
+
const rt = await cweRoute(config);
|
|
1842
|
+
|
|
1843
|
+
if (config.silent) {
|
|
1844
|
+
outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const cweTag = '[synkro:' + rt + ':cweScan]';
|
|
1849
|
+
|
|
1850
|
+
if (rt === 'local') {
|
|
1851
|
+
// \u2500\u2500\u2500 Local CWE grading on channel 2 (port 8930) \u2500\u2500\u2500
|
|
1852
|
+
let cweRules: any[] = [];
|
|
1853
|
+
try {
|
|
1854
|
+
const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
|
|
1855
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
1856
|
+
signal: AbortSignal.timeout(4000),
|
|
1857
|
+
});
|
|
1858
|
+
const data = await resp.json() as any;
|
|
1859
|
+
cweRules = data.rules || [];
|
|
1860
|
+
} catch {}
|
|
1861
|
+
|
|
1862
|
+
if (cweRules.length === 0) {
|
|
1863
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (no CWE rules for ' + fileExt + ')' });
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1868
|
+
const graderPrompt = [
|
|
1869
|
+
'File: ' + filePath,
|
|
1870
|
+
'Content (first 4000 chars):',
|
|
1871
|
+
proposedShort,
|
|
1872
|
+
'',
|
|
1873
|
+
'CWE rules to check against:',
|
|
1874
|
+
JSON.stringify(cweRules),
|
|
1875
|
+
].join('\\n');
|
|
1876
|
+
|
|
1877
|
+
let gradeResp: string;
|
|
1878
|
+
try {
|
|
1879
|
+
gradeResp = await localGradeCwe(graderPrompt);
|
|
1880
|
+
} catch {
|
|
1881
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 grader unavailable, skipped' });
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
const verdict = parseVerdict(gradeResp);
|
|
1886
|
+
|
|
1887
|
+
if (!verdict.ok) {
|
|
1888
|
+
// Extract all CWE rule_ids from the raw response
|
|
1889
|
+
const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
|
|
1890
|
+
const cweIds: string[] = [];
|
|
1891
|
+
for (const match of ruleIdMatches.slice(0, 5)) {
|
|
1892
|
+
const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
|
|
1893
|
+
if (id && !cweIds.includes(id)) cweIds.push(id);
|
|
1894
|
+
}
|
|
1895
|
+
const displayIds = cweIds.slice(0, 3).join(', ');
|
|
1896
|
+
const count = cweIds.length;
|
|
1897
|
+
const label = count === 1 ? 'match' : 'matches';
|
|
1898
|
+
const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
|
|
1899
|
+
const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
|
|
1900
|
+
const ctx = 'CWE: ' + denyDetail + '\\nFix all issues before retrying.';
|
|
1901
|
+
|
|
1902
|
+
outputJson({
|
|
1903
|
+
systemMessage: cweMsg,
|
|
1904
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
|
|
1905
|
+
});
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// \u2500\u2500\u2500 Cloud CWE grading (handled by server) \u2500\u2500\u2500
|
|
1914
|
+
// Cloud edit precheck already includes CWE \u2014 this hook is a no-op for cloud.
|
|
1915
|
+
outputEmpty();
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
process.stderr.write('[synkro] cweGuard error: ' + String(err) + '\\n');
|
|
1918
|
+
outputEmpty();
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
main();
|
|
1923
|
+
`;
|
|
1924
|
+
CVE_PRECHECK_TS = `#!/usr/bin/env bun
|
|
1925
|
+
import {
|
|
1926
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
|
|
1927
|
+
reconstructContent, readStdin, findNearestDeps, log,
|
|
1928
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
1929
|
+
} from './_synkro-common.ts';
|
|
1930
|
+
import { basename } from 'node:path';
|
|
1931
|
+
|
|
1932
|
+
const MANIFEST_NAMES = new Set([
|
|
1933
|
+
'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
|
|
1934
|
+
'Pipfile', 'go.mod', 'go.sum', 'Gemfile', 'pom.xml', 'Cargo.toml', 'composer.json', 'pyproject.toml',
|
|
1935
|
+
]);
|
|
1936
|
+
|
|
1937
|
+
function isManifest(filename: string): boolean {
|
|
1938
|
+
if (MANIFEST_NAMES.has(filename)) return true;
|
|
1939
|
+
if (filename.startsWith('requirements') && filename.endsWith('.txt')) return true;
|
|
1940
|
+
if (filename.startsWith('build.gradle')) return true;
|
|
1941
|
+
if (filename.endsWith('.cabal')) return true;
|
|
1942
|
+
return false;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function main() {
|
|
1946
|
+
try {
|
|
1947
|
+
const input = await readStdin();
|
|
1948
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1949
|
+
|
|
1950
|
+
const payload = JSON.parse(input);
|
|
1951
|
+
const toolName = payload.tool_name || '';
|
|
1952
|
+
if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1953
|
+
outputEmpty();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const toolInput = payload.tool_input || {};
|
|
1958
|
+
const cwd = payload.cwd || '';
|
|
1959
|
+
|
|
1960
|
+
const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
|
|
1961
|
+
if (!filePath) { outputEmpty(); return; }
|
|
1962
|
+
|
|
1963
|
+
const fileShort = basename(filePath);
|
|
1964
|
+
|
|
1965
|
+
let jwt = loadJwt();
|
|
1966
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1967
|
+
jwt = await ensureFreshJwt(jwt);
|
|
1968
|
+
|
|
1969
|
+
const config = await loadConfig(jwt);
|
|
1970
|
+
const rt = await route(config);
|
|
1971
|
+
|
|
1972
|
+
if (config.silent) {
|
|
1973
|
+
outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const cveTag = '[synkro:' + rt + ':cveScan]';
|
|
1978
|
+
|
|
1979
|
+
// Reconstruct proposed content
|
|
1980
|
+
const proposed = reconstructContent(toolName, toolInput, filePath);
|
|
1981
|
+
if (!proposed) {
|
|
1982
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const proposedShort = proposed.slice(0, 4000);
|
|
1987
|
+
|
|
1988
|
+
// For code files, find nearest package.json and extract deps
|
|
1989
|
+
let deps: Record<string, string> = {};
|
|
1990
|
+
if (!isManifest(fileShort)) {
|
|
1991
|
+
deps = findNearestDeps(filePath);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// CVE scan via OSV API
|
|
1995
|
+
const cveBody = {
|
|
1996
|
+
file_path: filePath,
|
|
1997
|
+
content: proposedShort,
|
|
1998
|
+
dependencies: deps,
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
let cveResp: any;
|
|
2002
|
+
try {
|
|
2003
|
+
const resp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
|
|
2004
|
+
method: 'POST',
|
|
2005
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
2006
|
+
body: JSON.stringify(cveBody),
|
|
2007
|
+
signal: AbortSignal.timeout(8000),
|
|
2008
|
+
});
|
|
2009
|
+
cveResp = await resp.json();
|
|
2010
|
+
} catch {
|
|
2011
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 error (timeout)' });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
|
|
2016
|
+
if (findings.length > 0) {
|
|
2017
|
+
const top3 = findings.slice(0, 3).map((f: any) => {
|
|
2018
|
+
const id = f.cve || f.id || '?';
|
|
2019
|
+
const pkg = f.package || '?';
|
|
2020
|
+
const ver = f.version || '?';
|
|
2021
|
+
const title = f.title || f.summary || 'vulnerable';
|
|
2022
|
+
return '[' + id + '] ' + pkg + '@' + ver + ': ' + title;
|
|
2023
|
+
}).join('; ');
|
|
2024
|
+
|
|
2025
|
+
const count = findings.length;
|
|
2026
|
+
const label = count === 1 ? 'advisory' : 'advisories';
|
|
2027
|
+
const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
|
|
2028
|
+
const ctx = 'CVE: ' + top3 + '\\nFix all issues before retrying.';
|
|
2029
|
+
|
|
2030
|
+
outputJson({
|
|
2031
|
+
systemMessage: cveMsg,
|
|
2032
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
|
|
2033
|
+
});
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
|
|
2040
|
+
outputEmpty();
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
main();
|
|
2045
|
+
`;
|
|
2046
|
+
BASH_JUDGE_TS = `#!/usr/bin/env bun
|
|
2047
|
+
import {
|
|
2048
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
2049
|
+
parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
|
|
2050
|
+
extractTranscript, readLastPrompt, log,
|
|
2051
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2052
|
+
type HookConfig, type Rule,
|
|
2053
|
+
} from './_synkro-common.ts';
|
|
2054
|
+
|
|
2055
|
+
async function main() {
|
|
2056
|
+
try {
|
|
2057
|
+
const input = await readStdin();
|
|
2058
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2059
|
+
|
|
2060
|
+
const payload = JSON.parse(input);
|
|
2061
|
+
const toolName = payload.tool_name || '';
|
|
2062
|
+
if (!['Bash', 'Read', 'Grep', 'Glob'].includes(toolName)) {
|
|
2063
|
+
outputEmpty();
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
const toolInput = payload.tool_input || {};
|
|
2068
|
+
const sessionId = payload.session_id || '';
|
|
2069
|
+
const toolUseId = payload.tool_use_id || '';
|
|
2070
|
+
const cwd = payload.cwd || '';
|
|
2071
|
+
const permissionMode = payload.permission_mode || '';
|
|
2072
|
+
const transcriptPath = payload.transcript_path || '';
|
|
2073
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2074
|
+
|
|
2075
|
+
let command = '';
|
|
2076
|
+
switch (toolName) {
|
|
2077
|
+
case 'Bash': command = toolInput.command || ''; break;
|
|
2078
|
+
case 'Read': command = 'cat ' + (toolInput.file_path || ''); break;
|
|
2079
|
+
case 'Grep': command = "grep -r '" + (toolInput.pattern || '') + "' " + (toolInput.path || '.'); break;
|
|
2080
|
+
case 'Glob': command = "find . -name '" + (toolInput.pattern || '') + "'"; break;
|
|
2081
|
+
}
|
|
2082
|
+
if (!command) { outputEmpty(); return; }
|
|
2083
|
+
|
|
2084
|
+
const cmdShort = command.slice(0, 80);
|
|
2085
|
+
log('bashGuard checking: ' + cmdShort);
|
|
2086
|
+
|
|
2087
|
+
let jwt = loadJwt();
|
|
2088
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2089
|
+
jwt = await ensureFreshJwt(jwt);
|
|
2090
|
+
|
|
2091
|
+
const transcript = extractTranscript(transcriptPath);
|
|
2092
|
+
const lastPrompt = readLastPrompt();
|
|
2093
|
+
|
|
2094
|
+
const config = await loadConfig(jwt);
|
|
2095
|
+
const rt = await route(config);
|
|
2096
|
+
const tagStr = tag(rt, config);
|
|
2097
|
+
|
|
2098
|
+
if (config.silent) {
|
|
2099
|
+
outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 skipped (silent mode)' });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
if (rt === 'local') {
|
|
2104
|
+
const graderPrompt = [
|
|
2105
|
+
'Working directory: ' + (cwd || '.'),
|
|
2106
|
+
'Repo: ' + (gitRepo || 'unknown'),
|
|
2107
|
+
'Command: ' + command,
|
|
2108
|
+
'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
|
|
2109
|
+
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
2110
|
+
'Org rules: ' + JSON.stringify(config.rules),
|
|
2111
|
+
].join('\\n');
|
|
2112
|
+
|
|
2113
|
+
let gradeResp: string;
|
|
2114
|
+
try {
|
|
2115
|
+
gradeResp = await localGrade('bash', graderPrompt);
|
|
2116
|
+
} catch {
|
|
2117
|
+
outputEmpty();
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const verdict = parseVerdict(gradeResp);
|
|
2122
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
2123
|
+
|
|
2124
|
+
if (!verdict.ok) {
|
|
2125
|
+
const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
|
|
2126
|
+
const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
|
|
2127
|
+
|
|
2128
|
+
if (mode === 'audit') {
|
|
2129
|
+
const reason = tagStr + ' bashGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
|
|
2130
|
+
outputJson({ systemMessage: reason });
|
|
2131
|
+
dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
|
|
2132
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2133
|
+
command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
|
|
2134
|
+
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
2135
|
+
});
|
|
2136
|
+
} else {
|
|
2137
|
+
const reason = tagStr + ' bashGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
|
|
2138
|
+
outputJson({
|
|
2139
|
+
systemMessage: reason,
|
|
2140
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
|
|
2141
|
+
});
|
|
2142
|
+
dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
|
|
2143
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2144
|
+
command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
|
|
2145
|
+
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
} else {
|
|
2149
|
+
outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
|
|
2150
|
+
dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
|
|
2151
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
2152
|
+
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
2153
|
+
rulesChecked: config.rules, violatedRules: [],
|
|
2154
|
+
recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
2161
|
+
const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
|
|
2162
|
+
|| process.env.SYNKRO_HEADLESS === '1';
|
|
2163
|
+
|
|
2164
|
+
const body: Record<string, any> = {
|
|
2165
|
+
hook_event: 'PreToolUse',
|
|
2166
|
+
tool_name: toolName,
|
|
2167
|
+
tool_input: toolInput,
|
|
2168
|
+
user_intent: transcript.userIntent || null,
|
|
2169
|
+
last_user_message: lastPrompt || null,
|
|
2170
|
+
recent_user_messages: transcript.recentUserMessages,
|
|
2171
|
+
recent_messages: transcript.recentMessages,
|
|
2172
|
+
recent_actions: transcript.recentActions,
|
|
2173
|
+
session_id: sessionId || null,
|
|
2174
|
+
tool_use_id: toolUseId || null,
|
|
2175
|
+
cwd: cwd || null,
|
|
2176
|
+
repo: gitRepo || null,
|
|
2177
|
+
permission_mode: permissionMode || null,
|
|
2178
|
+
headless: isHeadless,
|
|
2179
|
+
cc_model: transcript.ccModel || null,
|
|
2180
|
+
cc_usage: transcript.ccUsage || {},
|
|
2181
|
+
session_summary: transcript.sessionSummary || null,
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
|
|
2185
|
+
|
|
2186
|
+
if (!resp) {
|
|
2187
|
+
log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
|
|
2188
|
+
outputEmpty();
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
if (!resp.hook_response || typeof resp.hook_response !== 'object') {
|
|
2193
|
+
log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
|
|
2194
|
+
outputEmpty();
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
outputJson(resp.hook_response);
|
|
2199
|
+
} catch (err) {
|
|
2200
|
+
process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
|
|
2201
|
+
outputEmpty();
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
main();
|
|
2206
|
+
`;
|
|
2207
|
+
PLAN_JUDGE_TS = `#!/usr/bin/env bun
|
|
2208
|
+
import {
|
|
2209
|
+
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
2210
|
+
parseVerdict, dispatchCapture, postWithRetry, readStdin, log,
|
|
2211
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2212
|
+
} from './_synkro-common.ts';
|
|
2213
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
2214
|
+
import { join } from 'node:path';
|
|
2215
|
+
import { homedir } from 'node:os';
|
|
2216
|
+
|
|
2217
|
+
function findLatestPlan(): string | null {
|
|
2218
|
+
const plansDir = join(homedir(), '.claude', 'plans');
|
|
2219
|
+
if (!existsSync(plansDir)) return null;
|
|
2220
|
+
try {
|
|
2221
|
+
const files = readdirSync(plansDir)
|
|
2222
|
+
.filter(f => f.endsWith('.md'))
|
|
2223
|
+
.map(f => ({ name: f, mtime: statSync(join(plansDir, f)).mtimeMs }))
|
|
2224
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
2225
|
+
return files.length > 0 ? join(plansDir, files[0].name) : null;
|
|
2226
|
+
} catch {
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function appendReviewToPlan(planFile: string, verdict: string): void {
|
|
2232
|
+
try {
|
|
2233
|
+
let content = readFileSync(planFile, 'utf-8');
|
|
2234
|
+
content = content.replace(/<!-- synkro-plan-review -->[\\s\\S]*?<!-- \\/synkro-plan-review -->/g, '').trimEnd();
|
|
2235
|
+
const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
2236
|
+
content += '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \\u2014 ' + now + '\\n\\n' + verdict + '\\n\\n<!-- /synkro-plan-review -->\\n';
|
|
2237
|
+
writeFileSync(planFile, content, 'utf-8');
|
|
2238
|
+
} catch {}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
async function main() {
|
|
2242
|
+
try {
|
|
2243
|
+
const input = await readStdin();
|
|
2244
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2245
|
+
|
|
2246
|
+
const payload = JSON.parse(input);
|
|
2247
|
+
const toolName = payload.tool_name || '';
|
|
2248
|
+
if (toolName !== 'ExitPlanMode') { outputEmpty(); return; }
|
|
2249
|
+
|
|
2250
|
+
const planFile = findLatestPlan();
|
|
2251
|
+
if (!planFile) { outputEmpty(); return; }
|
|
2252
|
+
const plan = readFileSync(planFile, 'utf-8');
|
|
2253
|
+
if (plan.length < 20) { outputEmpty(); return; }
|
|
2254
|
+
|
|
2255
|
+
const sessionId = payload.session_id || '';
|
|
2256
|
+
const cwd = payload.cwd || '';
|
|
2257
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2258
|
+
|
|
2259
|
+
const planShort = plan.slice(0, 80);
|
|
2260
|
+
log('planReview checking: ' + planShort + '...');
|
|
2261
|
+
|
|
2262
|
+
let jwt = loadJwt();
|
|
2263
|
+
if (!jwt) { outputEmpty(); return; }
|
|
2264
|
+
jwt = await ensureFreshJwt(jwt);
|
|
2265
|
+
|
|
2266
|
+
const config = await loadConfig(jwt);
|
|
2267
|
+
const rt = await route(config);
|
|
2268
|
+
const tagStr = tag(rt, config);
|
|
2269
|
+
|
|
2270
|
+
if (config.silent) {
|
|
2271
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (rt === 'local') {
|
|
2276
|
+
const graderPrompt = [
|
|
2277
|
+
'Working directory: ' + (cwd || '.'),
|
|
2278
|
+
'Repo: ' + (gitRepo || 'unknown'),
|
|
2279
|
+
'Plan:',
|
|
2280
|
+
plan.slice(0, 8000),
|
|
2281
|
+
'Org rules: ' + JSON.stringify(config.rules),
|
|
2282
|
+
].join('\\n');
|
|
2283
|
+
|
|
2284
|
+
let gradeResp: string;
|
|
2285
|
+
try {
|
|
2286
|
+
gradeResp = await localGrade('plan', graderPrompt);
|
|
2287
|
+
} catch {
|
|
2288
|
+
outputEmpty();
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const verdict = parseVerdict(gradeResp);
|
|
2293
|
+
const planContent = plan.slice(0, 2000);
|
|
2294
|
+
const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
|
|
2295
|
+
|
|
2296
|
+
if (!verdict.ok) {
|
|
2297
|
+
const reviewMsg = (verdict.ruleId ? '(first: ' + verdict.ruleId + ') ' : '') + (verdict.reason || 'check org rules during implementation');
|
|
2298
|
+
appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reviewMsg);
|
|
2299
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 ' + reviewMsg });
|
|
2300
|
+
dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
|
|
2301
|
+
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
2302
|
+
command: planContent, reasoning: verdict.reason || 'check org rules',
|
|
2303
|
+
rulesChecked: config.rules, violatedRules,
|
|
2304
|
+
});
|
|
2305
|
+
} else {
|
|
2306
|
+
const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
|
|
2307
|
+
appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
|
|
2308
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 clean: ' + reviewMsg });
|
|
2309
|
+
dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
|
|
2310
|
+
'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
|
|
2311
|
+
command: planContent, reasoning: reviewMsg,
|
|
2312
|
+
rulesChecked: config.rules, violatedRules: [],
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
|
|
2319
|
+
const body = {
|
|
2320
|
+
hook_event: 'PreToolUse',
|
|
2321
|
+
tool_name: 'ExitPlanMode',
|
|
2322
|
+
tool_input: { plan: plan.slice(0, 16000) },
|
|
2323
|
+
session_id: sessionId || null,
|
|
2324
|
+
cwd: cwd || null,
|
|
2325
|
+
repo: gitRepo || null,
|
|
2326
|
+
};
|
|
2327
|
+
|
|
2328
|
+
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
|
|
2329
|
+
|
|
2330
|
+
if (!resp) {
|
|
2331
|
+
log('planReview \\u2192 error (timeout)');
|
|
2332
|
+
outputEmpty();
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
const hookResp = resp?.hook_response;
|
|
2337
|
+
if (!hookResp) { outputEmpty(); return; }
|
|
2338
|
+
|
|
2339
|
+
const decision = hookResp?.hookSpecificOutput?.permissionDecision;
|
|
2340
|
+
if (decision) {
|
|
2341
|
+
const reason = hookResp?.hookSpecificOutput?.permissionDecisionReason || 'check org rules';
|
|
2342
|
+
appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reason);
|
|
2343
|
+
outputJson({ systemMessage: tagStr + ' planReview \\u2192 advisory: ' + reason });
|
|
2344
|
+
} else {
|
|
2345
|
+
const cloudMsg = hookResp.systemMessage || '';
|
|
2346
|
+
if (cloudMsg) appendReviewToPlan(planFile, '\\u2705 ' + cloudMsg);
|
|
2347
|
+
outputJson(hookResp);
|
|
2348
|
+
}
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
process.stderr.write('[synkro] planReview error: ' + String(err) + '\\n');
|
|
2351
|
+
outputEmpty();
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
main();
|
|
2356
|
+
`;
|
|
2357
|
+
STOP_SUMMARY_TS = `#!/usr/bin/env bun
|
|
2358
|
+
import {
|
|
2359
|
+
loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
|
|
2360
|
+
outputJson, outputEmpty, GATEWAY_URL,
|
|
2361
|
+
} from './_synkro-common.ts';
|
|
2362
|
+
|
|
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';
|
|
2449
|
+
|
|
2450
|
+
async function main() {
|
|
2451
|
+
try {
|
|
2452
|
+
const input = await readStdin();
|
|
2453
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
2454
|
+
|
|
2455
|
+
const payload = JSON.parse(input);
|
|
2456
|
+
const cwd = payload.cwd || '';
|
|
2457
|
+
const sessionId = payload.session_id || '';
|
|
2458
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2459
|
+
|
|
2460
|
+
let jwt = loadJwt();
|
|
2461
|
+
|
|
2462
|
+
const isChannelUp = await channelUp();
|
|
2463
|
+
const rt = isChannelUp ? 'local' : 'cloud';
|
|
2464
|
+
|
|
2465
|
+
let policyName = '';
|
|
2466
|
+
let silent = false;
|
|
2467
|
+
let openFindings = 0;
|
|
2468
|
+
|
|
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
|
+
}
|
|
2482
|
+
|
|
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)');
|
|
2486
|
+
|
|
2487
|
+
if (!jwt) {
|
|
2488
|
+
outputJson({ systemMessage: routeLine });
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
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();
|
|
2506
|
+
`;
|
|
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';
|
|
1743
2512
|
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
2513
|
+
async function main() {
|
|
2514
|
+
try {
|
|
2515
|
+
const input = await readStdin();
|
|
2516
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1747
2517
|
|
|
1748
|
-
|
|
1749
|
-
|
|
2518
|
+
const payload = JSON.parse(input);
|
|
2519
|
+
const toolName = payload.tool_name || '';
|
|
2520
|
+
if (toolName !== 'Bash') { outputEmpty(); return; }
|
|
1750
2521
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
|
|
1754
|
-
GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
|
|
2522
|
+
const jwt = loadJwt();
|
|
2523
|
+
if (!jwt) { outputEmpty(); return; }
|
|
1755
2524
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
if
|
|
2525
|
+
const sessionId = payload.session_id || '';
|
|
2526
|
+
const toolUseId = payload.tool_use_id || '';
|
|
2527
|
+
if (!sessionId || !toolUseId) { outputEmpty(); return; }
|
|
1759
2528
|
|
|
1760
|
-
|
|
1761
|
-
|
|
2529
|
+
const isError = payload.tool_result?.is_error === true;
|
|
2530
|
+
const cmd = payload.tool_input?.command || '';
|
|
2531
|
+
const cmdHash = cmd ? hashCommand(cmd) : '';
|
|
1762
2532
|
|
|
1763
|
-
|
|
1764
|
-
if
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
+
}
|
|
1767
2542
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
hook_event: "PreToolUse",
|
|
1776
|
-
tool_name: "Edit",
|
|
1777
|
-
tool_input: {file_path: $file_path, content: $content},
|
|
1778
|
-
file_path: $file_path,
|
|
1779
|
-
content: $content,
|
|
1780
|
-
response_format: "cursor",
|
|
1781
|
-
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
1782
|
-
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
1783
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
1784
|
-
}')
|
|
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
|
+
};
|
|
1785
2550
|
|
|
1786
|
-
|
|
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(() => {});
|
|
1787
2557
|
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2558
|
+
outputEmpty();
|
|
2559
|
+
} catch {
|
|
2560
|
+
outputEmpty();
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
1792
2563
|
|
|
1793
|
-
|
|
1794
|
-
echo "$RESP" | jq -c '.hook_response'
|
|
1795
|
-
else
|
|
1796
|
-
echo '{}'
|
|
1797
|
-
fi
|
|
1798
|
-
exit 0
|
|
2564
|
+
main();
|
|
1799
2565
|
`;
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
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';
|
|
1803
2574
|
|
|
1804
|
-
|
|
1805
|
-
|
|
2575
|
+
async function main() {
|
|
2576
|
+
try {
|
|
2577
|
+
const input = await readStdin();
|
|
2578
|
+
if (!input.trim()) { outputEmpty(); return; }
|
|
1806
2579
|
|
|
1807
|
-
|
|
1808
|
-
|
|
2580
|
+
const payload = JSON.parse(input);
|
|
2581
|
+
const sessionId = payload.session_id || '';
|
|
2582
|
+
const transcriptPath = payload.transcript_path || '';
|
|
2583
|
+
const cwd = payload.cwd || '';
|
|
1809
2584
|
|
|
1810
|
-
|
|
1811
|
-
|
|
2585
|
+
if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
|
|
2586
|
+
outputEmpty();
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
1812
2589
|
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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
|
+
}
|
|
1817
2618
|
|
|
1818
|
-
|
|
1819
|
-
[ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
|
|
1820
|
-
FULL_CONTENT=""
|
|
1821
|
-
[ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
|
|
2619
|
+
if (process.env.SYNKRO_TRANSCRIPT_CONSENT === 'no') { outputEmpty(); return; }
|
|
1822
2620
|
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
while [ "$_PKG_DIR" != "/" ]; do
|
|
1826
|
-
if [ -f "$_PKG_DIR/package.json" ]; then
|
|
1827
|
-
DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
|
|
1828
|
-
break
|
|
1829
|
-
fi
|
|
1830
|
-
_PKG_DIR=$(dirname "$_PKG_DIR")
|
|
1831
|
-
done
|
|
2621
|
+
const gitRepo = detectRepo(cwd || '.');
|
|
2622
|
+
if (!gitRepo) { outputEmpty(); return; }
|
|
1832
2623
|
|
|
1833
|
-
|
|
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
|
+
}
|
|
1834
2643
|
|
|
1835
|
-
(
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
--arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
|
|
1839
|
-
--argjson deps "$DEPS_JSON" \\
|
|
1840
|
-
'{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
|
|
1841
|
-
+ (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
|
|
1842
|
-
+ (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
|
|
1843
|
-
+ (if ($repo | length) > 0 then {repo:$repo} else {} end)')
|
|
1844
|
-
curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
|
|
1845
|
-
-H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
|
|
1846
|
-
-d "$BODY" --max-time 10 >/dev/null 2>&1 || true
|
|
1847
|
-
) &
|
|
1848
|
-
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;
|
|
1849
2647
|
|
|
1850
|
-
|
|
1851
|
-
exit 0
|
|
1852
|
-
`;
|
|
1853
|
-
CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
|
|
1854
|
-
SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1855
|
-
. "$SCRIPT_DIR/_synkro-common.sh"
|
|
2648
|
+
if (totalLines <= offset) { outputEmpty(); return; }
|
|
1856
2649
|
|
|
1857
|
-
|
|
1858
|
-
|
|
2650
|
+
let startIdx = offset;
|
|
2651
|
+
const delta = totalLines - offset;
|
|
2652
|
+
if (delta > 200) startIdx = totalLines - 200;
|
|
1859
2653
|
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
+
}
|
|
1863
2669
|
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
+
}
|
|
1866
2683
|
|
|
1867
|
-
|
|
1868
|
-
CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
1869
|
-
CMD_HASH=""
|
|
1870
|
-
if [ -n "$CMD" ]; then
|
|
1871
|
-
CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
|
|
1872
|
-
fi
|
|
2684
|
+
writeFileSync(offsetFile, String(totalLines), 'utf-8');
|
|
1873
2685
|
|
|
1874
|
-
if
|
|
1875
|
-
if [ "$IS_ERROR" = "false" ]; then
|
|
1876
|
-
synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
|
|
1877
|
-
else
|
|
1878
|
-
if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
|
|
1879
|
-
synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
|
|
1880
|
-
fi
|
|
1881
|
-
fi
|
|
1882
|
-
fi
|
|
2686
|
+
if (messages.length === 0) { outputEmpty(); return; }
|
|
1883
2687
|
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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
|
+
}
|
|
1895
2704
|
|
|
1896
|
-
|
|
1897
|
-
|
|
2705
|
+
main();
|
|
2706
|
+
`;
|
|
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();
|
|
1898
2728
|
`;
|
|
1899
2729
|
}
|
|
1900
2730
|
});
|
|
@@ -3223,27 +4053,40 @@ function writePluginFiles() {
|
|
|
3223
4053
|
PLUGIN_SETTINGS_PATH,
|
|
3224
4054
|
JSON.stringify({
|
|
3225
4055
|
fastMode: true,
|
|
3226
|
-
// Pre-approve the project-local synkro-local MCP server so claude doesn't
|
|
3227
|
-
// block on a consent prompt at startup. Lives in the PROJECT settings so
|
|
3228
|
-
// it's still picked up under --setting-sources project,local (which
|
|
3229
|
-
// skips user settings to avoid synkro-hook recursion in the grader).
|
|
3230
4056
|
enabledMcpjsonServers: ["synkro-local"]
|
|
3231
4057
|
}, null, 2) + "\n",
|
|
3232
4058
|
"utf-8"
|
|
3233
4059
|
);
|
|
3234
4060
|
writeFileSync6(RUN_SCRIPT_PATH, RUN_SCRIPT_SOURCE, "utf-8");
|
|
3235
4061
|
chmodSync(RUN_SCRIPT_PATH, 493);
|
|
4062
|
+
mkdirSync6(SESSION_DIR_2, { recursive: true });
|
|
4063
|
+
mkdirSync6(PLUGIN_SETTINGS_DIR_2, { recursive: true });
|
|
4064
|
+
writeFileSync6(PLUGIN_PATH_2, CHANNEL_PLUGIN_SOURCE, "utf-8");
|
|
4065
|
+
chmodSync(PLUGIN_PATH_2, 493);
|
|
4066
|
+
writeFileSync6(PLUGIN_PKG_PATH_2, PLUGIN_PACKAGE_JSON, "utf-8");
|
|
4067
|
+
writeFileSync6(
|
|
4068
|
+
PLUGIN_SETTINGS_PATH_2,
|
|
4069
|
+
JSON.stringify({
|
|
4070
|
+
fastMode: true,
|
|
4071
|
+
enabledMcpjsonServers: ["synkro-local"]
|
|
4072
|
+
}, null, 2) + "\n",
|
|
4073
|
+
"utf-8"
|
|
4074
|
+
);
|
|
4075
|
+
writeFileSync6(RUN_SCRIPT_PATH_2, RUN_SCRIPT_SOURCE_2, "utf-8");
|
|
4076
|
+
chmodSync(RUN_SCRIPT_PATH_2, 493);
|
|
3236
4077
|
}
|
|
3237
4078
|
function runBunInstall() {
|
|
3238
|
-
const
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
4079
|
+
for (const dir of [SESSION_DIR, SESSION_DIR_2]) {
|
|
4080
|
+
const r = spawnSync("bun", ["install", "--silent"], {
|
|
4081
|
+
cwd: dir,
|
|
4082
|
+
encoding: "utf-8",
|
|
4083
|
+
timeout: 12e4
|
|
4084
|
+
});
|
|
4085
|
+
if (r.status !== 0) {
|
|
4086
|
+
throw new LocalCCInstallError(
|
|
4087
|
+
`bun install failed in ${dir}: ${r.stderr || r.stdout || "unknown"}`
|
|
4088
|
+
);
|
|
4089
|
+
}
|
|
3247
4090
|
}
|
|
3248
4091
|
}
|
|
3249
4092
|
function safelyMutateClaudeJson(mutator) {
|
|
@@ -3315,6 +4158,15 @@ function writeProjectMcpJson() {
|
|
|
3315
4158
|
}
|
|
3316
4159
|
};
|
|
3317
4160
|
writeFileSync6(PROJECT_MCP_PATH, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
|
|
4161
|
+
const mcp2 = {
|
|
4162
|
+
mcpServers: {
|
|
4163
|
+
[MCP_SERVER_NAME]: {
|
|
4164
|
+
command: "bun",
|
|
4165
|
+
args: [PLUGIN_PATH_2]
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
};
|
|
4169
|
+
writeFileSync6(PROJECT_MCP_PATH_2, JSON.stringify(mcp2, null, 2) + "\n", "utf-8");
|
|
3318
4170
|
}
|
|
3319
4171
|
function patchClaudeJson() {
|
|
3320
4172
|
safelyMutateClaudeJson((parsed) => {
|
|
@@ -3327,20 +4179,22 @@ function patchClaudeJson() {
|
|
|
3327
4179
|
parsed.projects = {};
|
|
3328
4180
|
}
|
|
3329
4181
|
const projects = parsed.projects;
|
|
3330
|
-
const
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
4182
|
+
for (const dir of [SESSION_DIR, SESSION_DIR_2]) {
|
|
4183
|
+
const existing = projects[dir] && typeof projects[dir] === "object" ? projects[dir] : {};
|
|
4184
|
+
const wantEnabled = Array.from(/* @__PURE__ */ new Set([
|
|
4185
|
+
...existing.enabledMcpjsonServers ?? [],
|
|
4186
|
+
MCP_SERVER_NAME
|
|
4187
|
+
]));
|
|
4188
|
+
const next = {
|
|
4189
|
+
...existing,
|
|
4190
|
+
hasTrustDialogAccepted: true,
|
|
4191
|
+
hasCompletedProjectOnboarding: true,
|
|
4192
|
+
enabledMcpjsonServers: wantEnabled
|
|
4193
|
+
};
|
|
4194
|
+
if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
|
|
4195
|
+
projects[dir] = next;
|
|
4196
|
+
dirty = true;
|
|
4197
|
+
}
|
|
3344
4198
|
}
|
|
3345
4199
|
return dirty;
|
|
3346
4200
|
});
|
|
@@ -3375,14 +4229,16 @@ function uninstallLocalCC() {
|
|
|
3375
4229
|
delete parsed.mcpServers[MCP_SERVER_NAME];
|
|
3376
4230
|
dirty = true;
|
|
3377
4231
|
}
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
4232
|
+
for (const dir of [SESSION_DIR, SESSION_DIR_2]) {
|
|
4233
|
+
if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[dir]) {
|
|
4234
|
+
delete parsed.projects[dir];
|
|
4235
|
+
dirty = true;
|
|
4236
|
+
}
|
|
3381
4237
|
}
|
|
3382
4238
|
return dirty;
|
|
3383
4239
|
});
|
|
3384
4240
|
}
|
|
3385
|
-
var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, RUN_SCRIPT_SOURCE, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError;
|
|
4241
|
+
var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, SESSION_DIR_2, PLUGIN_PATH_2, PLUGIN_PKG_PATH_2, PLUGIN_SETTINGS_DIR_2, PLUGIN_SETTINGS_PATH_2, PROJECT_MCP_PATH_2, RUN_SCRIPT_PATH_2, TMUX_SESSION_NAME_2, CHANNEL_2_PORT, RUN_SCRIPT_SOURCE, RUN_SCRIPT_SOURCE_2, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError;
|
|
3386
4242
|
var init_install = __esm({
|
|
3387
4243
|
"cli/local-cc/install.ts"() {
|
|
3388
4244
|
"use strict";
|
|
@@ -3397,6 +4253,15 @@ var init_install = __esm({
|
|
|
3397
4253
|
CLAUDE_JSON_PATH = join7(homedir6(), ".claude.json");
|
|
3398
4254
|
RUN_SCRIPT_PATH = join7(SESSION_DIR, "run-claude.sh");
|
|
3399
4255
|
TMUX_SESSION_NAME = "synkro-local-cc";
|
|
4256
|
+
SESSION_DIR_2 = join7(homedir6(), ".synkro", "cc_sessions_2");
|
|
4257
|
+
PLUGIN_PATH_2 = join7(SESSION_DIR_2, "synkro-channel.ts");
|
|
4258
|
+
PLUGIN_PKG_PATH_2 = join7(SESSION_DIR_2, "package.json");
|
|
4259
|
+
PLUGIN_SETTINGS_DIR_2 = join7(SESSION_DIR_2, ".claude");
|
|
4260
|
+
PLUGIN_SETTINGS_PATH_2 = join7(PLUGIN_SETTINGS_DIR_2, "settings.json");
|
|
4261
|
+
PROJECT_MCP_PATH_2 = join7(SESSION_DIR_2, ".mcp.json");
|
|
4262
|
+
RUN_SCRIPT_PATH_2 = join7(SESSION_DIR_2, "run-claude.sh");
|
|
4263
|
+
TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
|
|
4264
|
+
CHANNEL_2_PORT = 8930;
|
|
3400
4265
|
RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
|
|
3401
4266
|
# Auto-generated by \`synkro install\`. Do not edit.
|
|
3402
4267
|
set -uo pipefail
|
|
@@ -3462,6 +4327,62 @@ while tmux has-session -t "$SESSION" 2>/dev/null; do
|
|
|
3462
4327
|
sleep 5
|
|
3463
4328
|
done
|
|
3464
4329
|
|
|
4330
|
+
log "tmux session ended."
|
|
4331
|
+
`;
|
|
4332
|
+
RUN_SCRIPT_SOURCE_2 = `#!/usr/bin/env bash
|
|
4333
|
+
# Auto-generated by \`synkro install\`. Channel 2 (CWE scan, port ${CHANNEL_2_PORT}).
|
|
4334
|
+
set -uo pipefail
|
|
4335
|
+
|
|
4336
|
+
SESSION=${TMUX_SESSION_NAME_2}
|
|
4337
|
+
LOG="$HOME/.synkro/cc_sessions_2/run-claude.log"
|
|
4338
|
+
|
|
4339
|
+
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
4340
|
+
|
|
4341
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
4342
|
+
log "ERROR: claude CLI not found on PATH."
|
|
4343
|
+
exit 1
|
|
4344
|
+
fi
|
|
4345
|
+
|
|
4346
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
4347
|
+
log "ERROR: tmux not found on PATH."
|
|
4348
|
+
exit 1
|
|
4349
|
+
fi
|
|
4350
|
+
|
|
4351
|
+
if ! claude --version >/dev/null 2>&1; then
|
|
4352
|
+
log "ERROR: claude --version failed."
|
|
4353
|
+
exit 1
|
|
4354
|
+
fi
|
|
4355
|
+
|
|
4356
|
+
log "Starting local-CC channel 2 (port ${CHANNEL_2_PORT})..."
|
|
4357
|
+
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
4358
|
+
|
|
4359
|
+
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
|
4360
|
+
|
|
4361
|
+
tmux new-session -d -s "$SESSION" \\
|
|
4362
|
+
"SYNKRO_CHANNEL_PORT=${CHANNEL_2_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
4363
|
+
|
|
4364
|
+
sleep 3
|
|
4365
|
+
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
4366
|
+
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
4367
|
+
sleep 1
|
|
4368
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
4369
|
+
sleep 1
|
|
4370
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
4371
|
+
log "Sent auto-accept keys to channel 2 session."
|
|
4372
|
+
fi
|
|
4373
|
+
|
|
4374
|
+
sleep 2
|
|
4375
|
+
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
4376
|
+
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
4377
|
+
exit 1
|
|
4378
|
+
fi
|
|
4379
|
+
|
|
4380
|
+
log "tmux session started successfully (port ${CHANNEL_2_PORT})."
|
|
4381
|
+
|
|
4382
|
+
while tmux has-session -t "$SESSION" 2>/dev/null; do
|
|
4383
|
+
sleep 5
|
|
4384
|
+
done
|
|
4385
|
+
|
|
3465
4386
|
log "tmux session ended."
|
|
3466
4387
|
`;
|
|
3467
4388
|
MCP_SERVER_NAME = "synkro-local";
|
|
@@ -3526,10 +4447,10 @@ function statusName(s) {
|
|
|
3526
4447
|
}
|
|
3527
4448
|
return "unknown";
|
|
3528
4449
|
}
|
|
3529
|
-
function findTask() {
|
|
4450
|
+
function findTask(channel = CHANNEL_PRIMARY) {
|
|
3530
4451
|
const data = statusJson();
|
|
3531
4452
|
for (const [id, t] of Object.entries(data.tasks)) {
|
|
3532
|
-
if (t.label ===
|
|
4453
|
+
if (t.label === channel.taskLabel) {
|
|
3533
4454
|
return {
|
|
3534
4455
|
id: Number(id),
|
|
3535
4456
|
label: t.label,
|
|
@@ -3542,8 +4463,9 @@ function findTask() {
|
|
|
3542
4463
|
return null;
|
|
3543
4464
|
}
|
|
3544
4465
|
function startTask(opts = {}) {
|
|
3545
|
-
const
|
|
3546
|
-
const
|
|
4466
|
+
const ch = opts.channel ?? CHANNEL_PRIMARY;
|
|
4467
|
+
const cwd = opts.cwd ?? ch.sessionDir;
|
|
4468
|
+
const existing = findTask(ch);
|
|
3547
4469
|
if (existing) {
|
|
3548
4470
|
spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
|
|
3549
4471
|
}
|
|
@@ -3551,7 +4473,7 @@ function startTask(opts = {}) {
|
|
|
3551
4473
|
const args2 = [
|
|
3552
4474
|
"add",
|
|
3553
4475
|
"--label",
|
|
3554
|
-
|
|
4476
|
+
ch.taskLabel,
|
|
3555
4477
|
"--working-directory",
|
|
3556
4478
|
cwd,
|
|
3557
4479
|
"--",
|
|
@@ -3562,27 +4484,28 @@ function startTask(opts = {}) {
|
|
|
3562
4484
|
if (r.status !== 0) {
|
|
3563
4485
|
throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
|
|
3564
4486
|
}
|
|
3565
|
-
const created = findTask();
|
|
4487
|
+
const created = findTask(ch);
|
|
3566
4488
|
if (!created) {
|
|
3567
|
-
throw new PueueError(`pueue add succeeded but no task with label ${
|
|
4489
|
+
throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
|
|
3568
4490
|
}
|
|
3569
4491
|
return created;
|
|
3570
4492
|
}
|
|
3571
|
-
function stopTask() {
|
|
3572
|
-
spawnSync2("tmux", ["kill-session", "-t",
|
|
3573
|
-
const t = findTask();
|
|
4493
|
+
function stopTask(channel = CHANNEL_PRIMARY) {
|
|
4494
|
+
spawnSync2("tmux", ["kill-session", "-t", channel.tmuxSession], { encoding: "utf-8" });
|
|
4495
|
+
const t = findTask(channel);
|
|
3574
4496
|
if (!t) return;
|
|
3575
4497
|
spawnSync2("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
|
|
3576
4498
|
spawnSync2("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
|
|
3577
4499
|
}
|
|
3578
|
-
function tailLogs(lines = 80) {
|
|
3579
|
-
const t = findTask();
|
|
3580
|
-
if (!t) return
|
|
4500
|
+
function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
|
|
4501
|
+
const t = findTask(channel);
|
|
4502
|
+
if (!t) return `(no ${channel.taskLabel} task)`;
|
|
3581
4503
|
const r = spawnSync2("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
|
|
3582
4504
|
return r.stdout || r.stderr || "(no output)";
|
|
3583
4505
|
}
|
|
3584
4506
|
function ensureRunning(opts = {}) {
|
|
3585
|
-
const
|
|
4507
|
+
const ch = opts.channel ?? CHANNEL_PRIMARY;
|
|
4508
|
+
const t = findTask(ch);
|
|
3586
4509
|
if (t && t.status === "Running") return t;
|
|
3587
4510
|
return startTask(opts);
|
|
3588
4511
|
}
|
|
@@ -3601,15 +4524,15 @@ function probePort(host, port, timeoutMs = 500) {
|
|
|
3601
4524
|
sock.setTimeout(timeoutMs, () => done(false));
|
|
3602
4525
|
});
|
|
3603
4526
|
}
|
|
3604
|
-
function tmuxDismissPrompts() {
|
|
3605
|
-
spawnSync2("tmux", ["send-keys", "-t",
|
|
3606
|
-
spawnSync2("tmux", ["send-keys", "-t",
|
|
4527
|
+
function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
|
|
4528
|
+
spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
|
|
4529
|
+
spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
|
|
3607
4530
|
}
|
|
3608
|
-
async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1") {
|
|
4531
|
+
async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
|
|
3609
4532
|
const deadline = Date.now() + timeoutMs;
|
|
3610
4533
|
while (Date.now() < deadline) {
|
|
3611
4534
|
if (await probePort(host, port)) return true;
|
|
3612
|
-
tmuxDismissPrompts();
|
|
4535
|
+
tmuxDismissPrompts(tmuxSession);
|
|
3613
4536
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
3614
4537
|
}
|
|
3615
4538
|
return probePort(host, port);
|
|
@@ -3642,6 +4565,7 @@ function assertPueueInstalled() {
|
|
|
3642
4565
|
throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
|
|
3643
4566
|
}
|
|
3644
4567
|
}
|
|
4568
|
+
spawnSync2("pueue", ["parallel", "2"], { encoding: "utf-8" });
|
|
3645
4569
|
}
|
|
3646
4570
|
function assertClaudeInstalled() {
|
|
3647
4571
|
const r = spawnSync2("claude", ["--version"], { encoding: "utf-8" });
|
|
@@ -3660,13 +4584,16 @@ function assertTmuxInstalled() {
|
|
|
3660
4584
|
}
|
|
3661
4585
|
}
|
|
3662
4586
|
}
|
|
3663
|
-
var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, PueueError;
|
|
4587
|
+
var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY;
|
|
3664
4588
|
var init_pueue = __esm({
|
|
3665
4589
|
"cli/local-cc/pueue.ts"() {
|
|
3666
4590
|
"use strict";
|
|
3667
4591
|
TASK_LABEL = "synkro-local-cc";
|
|
3668
4592
|
TMUX_SESSION = "synkro-local-cc";
|
|
3669
4593
|
SESSION_DIR2 = join8(homedir7(), ".synkro", "cc_sessions");
|
|
4594
|
+
TASK_LABEL_2 = "synkro-local-cc-2";
|
|
4595
|
+
TMUX_SESSION_2 = "synkro-local-cc-2";
|
|
4596
|
+
SESSION_DIR_22 = join8(homedir7(), ".synkro", "cc_sessions_2");
|
|
3670
4597
|
PueueError = class extends Error {
|
|
3671
4598
|
constructor(message, cause) {
|
|
3672
4599
|
super(message);
|
|
@@ -3675,6 +4602,8 @@ var init_pueue = __esm({
|
|
|
3675
4602
|
}
|
|
3676
4603
|
cause;
|
|
3677
4604
|
};
|
|
4605
|
+
CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
|
|
4606
|
+
CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
|
|
3678
4607
|
}
|
|
3679
4608
|
});
|
|
3680
4609
|
|
|
@@ -3702,7 +4631,7 @@ async function fetchPrimers() {
|
|
|
3702
4631
|
}
|
|
3703
4632
|
async function getPrimer(role) {
|
|
3704
4633
|
const prompts = await fetchPrimers();
|
|
3705
|
-
const primer = role === "grade-edit" ? prompts.grader_primer_edit : role === "grade-plan" ? prompts.grader_primer_plan : prompts.grader_primer_bash;
|
|
4634
|
+
const primer = role === "grade-edit" ? prompts.grader_primer_edit : role === "grade-plan" ? prompts.grader_primer_plan : role === "grade-cwe" ? prompts.grader_primer_cwe : prompts.grader_primer_bash;
|
|
3706
4635
|
if (!primer) {
|
|
3707
4636
|
throw new Error(`No primer for role "${role}" returned from API.`);
|
|
3708
4637
|
}
|
|
@@ -3868,12 +4797,13 @@ async function submitToChannel(role, payload, opts = {}) {
|
|
|
3868
4797
|
const content = await buildChannelContent(role, payload);
|
|
3869
4798
|
const body = JSON.stringify({ role, content });
|
|
3870
4799
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
4800
|
+
const port = opts.port ?? CHANNEL_PORT;
|
|
3871
4801
|
const startedAt = Date.now();
|
|
3872
4802
|
try {
|
|
3873
4803
|
const result = await new Promise((resolve2, reject) => {
|
|
3874
4804
|
const req = httpRequest({
|
|
3875
4805
|
host: CHANNEL_HOST,
|
|
3876
|
-
port
|
|
4806
|
+
port,
|
|
3877
4807
|
method: "POST",
|
|
3878
4808
|
path: "/submit",
|
|
3879
4809
|
headers: {
|
|
@@ -3921,9 +4851,9 @@ async function submitToChannel(role, payload, opts = {}) {
|
|
|
3921
4851
|
throw err;
|
|
3922
4852
|
}
|
|
3923
4853
|
}
|
|
3924
|
-
function isChannelAvailable(timeoutMs = 500) {
|
|
4854
|
+
function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
|
|
3925
4855
|
return new Promise((resolve2) => {
|
|
3926
|
-
const sock = connect2(
|
|
4856
|
+
const sock = connect2(port, CHANNEL_HOST);
|
|
3927
4857
|
const done = (ok) => {
|
|
3928
4858
|
try {
|
|
3929
4859
|
sock.destroy();
|
|
@@ -4007,30 +4937,36 @@ function ensureSynkroDir() {
|
|
|
4007
4937
|
mkdirSync8(OFFSETS_DIR, { recursive: true });
|
|
4008
4938
|
}
|
|
4009
4939
|
function writeHookScripts() {
|
|
4010
|
-
const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.
|
|
4011
|
-
const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.
|
|
4940
|
+
const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
|
|
4941
|
+
const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
|
|
4012
4942
|
const editCaptureScriptPath = join11(HOOKS_DIR, "cc-edit-capture.sh");
|
|
4013
|
-
const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.
|
|
4014
|
-
const
|
|
4015
|
-
const
|
|
4016
|
-
const
|
|
4017
|
-
const
|
|
4018
|
-
const
|
|
4019
|
-
const
|
|
4943
|
+
const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
|
|
4944
|
+
const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
|
|
4945
|
+
const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
|
|
4946
|
+
const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
|
|
4947
|
+
const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
|
|
4948
|
+
const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
|
|
4949
|
+
const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
|
|
4950
|
+
const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
|
|
4951
|
+
const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
|
|
4952
|
+
const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
|
|
4020
4953
|
const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
|
|
4021
4954
|
const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
|
|
4022
4955
|
const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
|
|
4023
4956
|
const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
|
|
4024
|
-
writeFileSync7(bashScriptPath,
|
|
4025
|
-
writeFileSync7(bashFollowupScriptPath,
|
|
4026
|
-
writeFileSync7(editCaptureScriptPath,
|
|
4027
|
-
writeFileSync7(editPrecheckScriptPath,
|
|
4028
|
-
writeFileSync7(
|
|
4029
|
-
writeFileSync7(
|
|
4030
|
-
writeFileSync7(
|
|
4031
|
-
writeFileSync7(
|
|
4032
|
-
writeFileSync7(
|
|
4033
|
-
writeFileSync7(
|
|
4957
|
+
writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
4958
|
+
writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
4959
|
+
writeFileSync7(editCaptureScriptPath, "", "utf-8");
|
|
4960
|
+
writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
|
|
4961
|
+
writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
|
|
4962
|
+
writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
|
|
4963
|
+
writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
|
|
4964
|
+
writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
|
|
4965
|
+
writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
|
|
4966
|
+
writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
|
|
4967
|
+
writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
|
|
4968
|
+
writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
|
|
4969
|
+
writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
|
|
4034
4970
|
writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
|
|
4035
4971
|
writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
|
|
4036
4972
|
writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
|
|
@@ -4039,12 +4975,15 @@ function writeHookScripts() {
|
|
|
4039
4975
|
chmodSync2(bashFollowupScriptPath, 493);
|
|
4040
4976
|
chmodSync2(editCaptureScriptPath, 493);
|
|
4041
4977
|
chmodSync2(editPrecheckScriptPath, 493);
|
|
4042
|
-
chmodSync2(
|
|
4978
|
+
chmodSync2(cwePrecheckScriptPath, 493);
|
|
4979
|
+
chmodSync2(cvePrecheckScriptPath, 493);
|
|
4043
4980
|
chmodSync2(planJudgeScriptPath, 493);
|
|
4044
4981
|
chmodSync2(stopSummaryScriptPath, 493);
|
|
4045
4982
|
chmodSync2(sessionStartScriptPath, 493);
|
|
4046
4983
|
chmodSync2(transcriptSyncScriptPath, 493);
|
|
4984
|
+
chmodSync2(userPromptSubmitScriptPath, 493);
|
|
4047
4985
|
chmodSync2(commonScriptPath, 493);
|
|
4986
|
+
chmodSync2(commonBashScriptPath, 493);
|
|
4048
4987
|
chmodSync2(cursorBashJudgePath, 493);
|
|
4049
4988
|
chmodSync2(cursorEditPrecheckPath, 493);
|
|
4050
4989
|
chmodSync2(cursorEditCapturePath, 493);
|
|
@@ -4054,11 +4993,13 @@ function writeHookScripts() {
|
|
|
4054
4993
|
bashFollowupScript: bashFollowupScriptPath,
|
|
4055
4994
|
editCaptureScript: editCaptureScriptPath,
|
|
4056
4995
|
editPrecheckScript: editPrecheckScriptPath,
|
|
4057
|
-
|
|
4996
|
+
cwePrecheckScript: cwePrecheckScriptPath,
|
|
4997
|
+
cvePrecheckScript: cvePrecheckScriptPath,
|
|
4058
4998
|
planJudgeScript: planJudgeScriptPath,
|
|
4059
4999
|
stopSummaryScript: stopSummaryScriptPath,
|
|
4060
5000
|
sessionStartScript: sessionStartScriptPath,
|
|
4061
5001
|
transcriptSyncScript: transcriptSyncScriptPath,
|
|
5002
|
+
userPromptSubmitScript: userPromptSubmitScriptPath,
|
|
4062
5003
|
cursorBashJudgeScript: cursorBashJudgePath,
|
|
4063
5004
|
cursorEditPrecheckScript: cursorEditPrecheckPath,
|
|
4064
5005
|
cursorEditCaptureScript: cursorEditCapturePath,
|
|
@@ -4094,7 +5035,7 @@ function writeConfigEnv(opts) {
|
|
|
4094
5035
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
4095
5036
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
4096
5037
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
4097
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.4.
|
|
5038
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.4.47")}`
|
|
4098
5039
|
];
|
|
4099
5040
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
4100
5041
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -4288,6 +5229,11 @@ async function installCommand(opts = {}) {
|
|
|
4288
5229
|
const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
|
|
4289
5230
|
if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
4290
5231
|
else console.warn(" \u26A0 channel did not come up within 60s \u2014 check `synkro local-cc logs`");
|
|
5232
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
5233
|
+
console.log(` CWE channel: id=${t2.id} status=${t2.status}`);
|
|
5234
|
+
const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
|
|
5235
|
+
if (ready2) console.log(` CWE channel ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
|
|
5236
|
+
else console.warn(" \u26A0 CWE channel did not come up within 60s");
|
|
4291
5237
|
updateLocalInferenceFlag(true);
|
|
4292
5238
|
} catch (err) {
|
|
4293
5239
|
console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
|
|
@@ -4397,11 +5343,13 @@ async function installCommand(opts = {}) {
|
|
|
4397
5343
|
bashFollowupScriptPath: scripts.bashFollowupScript,
|
|
4398
5344
|
editCaptureScriptPath: scripts.editCaptureScript,
|
|
4399
5345
|
editPrecheckScriptPath: scripts.editPrecheckScript,
|
|
4400
|
-
|
|
5346
|
+
cwePrecheckScriptPath: scripts.cwePrecheckScript,
|
|
5347
|
+
cvePrecheckScriptPath: scripts.cvePrecheckScript,
|
|
4401
5348
|
planJudgeScriptPath: scripts.planJudgeScript,
|
|
4402
5349
|
stopSummaryScriptPath: scripts.stopSummaryScript,
|
|
4403
5350
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
4404
5351
|
transcriptSyncScriptPath: scripts.transcriptSyncScript,
|
|
5352
|
+
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
4405
5353
|
skipTranscriptSync: !transcriptConsent
|
|
4406
5354
|
});
|
|
4407
5355
|
console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
|
|
@@ -4542,6 +5490,24 @@ async function installCommand(opts = {}) {
|
|
|
4542
5490
|
} catch {
|
|
4543
5491
|
}
|
|
4544
5492
|
console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.
|
|
5493
|
+
`);
|
|
5494
|
+
}
|
|
5495
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
5496
|
+
console.log(`Local-CC CWE channel: id=${t2.id} status=${t2.status}`);
|
|
5497
|
+
console.log("Waiting for CWE channel (up to 60s)...");
|
|
5498
|
+
const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
|
|
5499
|
+
if (ready2) {
|
|
5500
|
+
console.log(` CWE channel ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
|
|
5501
|
+
try {
|
|
5502
|
+
console.log(" warming up CWE inference...");
|
|
5503
|
+
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 });
|
|
5504
|
+
console.log(" CWE inference warm\n");
|
|
5505
|
+
} catch {
|
|
5506
|
+
console.log(" CWE warmup skipped (non-fatal)\n");
|
|
5507
|
+
}
|
|
5508
|
+
} else {
|
|
5509
|
+
console.warn(` \u26A0 CWE channel did not come up within 60s.`);
|
|
5510
|
+
console.warn(` Run \`synkro local-cc status\` to debug.
|
|
4545
5511
|
`);
|
|
4546
5512
|
}
|
|
4547
5513
|
} catch (err) {
|
|
@@ -4783,6 +5749,7 @@ var init_install2 = __esm({
|
|
|
4783
5749
|
init_cursorHookConfig();
|
|
4784
5750
|
init_mcpConfig();
|
|
4785
5751
|
init_hookScripts();
|
|
5752
|
+
init_hookScriptsTs();
|
|
4786
5753
|
init_stub();
|
|
4787
5754
|
init_repoConnect();
|
|
4788
5755
|
init_projects();
|
|
@@ -6173,17 +7140,26 @@ async function cmdStatus() {
|
|
|
6173
7140
|
console.log(`Pueue: NOT AVAILABLE (${err.message})`);
|
|
6174
7141
|
return;
|
|
6175
7142
|
}
|
|
6176
|
-
const t = findTask();
|
|
7143
|
+
const t = findTask(CHANNEL_PRIMARY);
|
|
6177
7144
|
if (!t) {
|
|
6178
|
-
console.log("
|
|
7145
|
+
console.log("Channel 1 (judge) pueue task: not present");
|
|
6179
7146
|
} else {
|
|
6180
|
-
console.log(`
|
|
6181
|
-
|
|
7147
|
+
console.log(`Channel 1 (judge) pueue task: id=${t.id} status=${t.status}`);
|
|
7148
|
+
}
|
|
7149
|
+
const ch1Up = await isChannelAvailable();
|
|
7150
|
+
console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
|
|
7151
|
+
const tmux1 = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
|
|
7152
|
+
console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
|
|
7153
|
+
const t2 = findTask(CHANNEL_SECONDARY);
|
|
7154
|
+
if (!t2) {
|
|
7155
|
+
console.log("Channel 2 (CWE) pueue task: not present");
|
|
7156
|
+
} else {
|
|
7157
|
+
console.log(`Channel 2 (CWE) pueue task: id=${t2.id} status=${t2.status}`);
|
|
6182
7158
|
}
|
|
6183
|
-
const
|
|
6184
|
-
console.log(`Channel ${CHANNEL_HOST}:${
|
|
6185
|
-
const
|
|
6186
|
-
console.log(`tmux
|
|
7159
|
+
const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
|
|
7160
|
+
console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
|
|
7161
|
+
const tmux2 = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME_2], { encoding: "utf-8" });
|
|
7162
|
+
console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
|
|
6187
7163
|
}
|
|
6188
7164
|
async function cmdEnable() {
|
|
6189
7165
|
assertClaudeInstalled();
|
|
@@ -6193,13 +7169,21 @@ async function cmdEnable() {
|
|
|
6193
7169
|
const r = installLocalCC();
|
|
6194
7170
|
console.log(` plugin: ${r.pluginPath}`);
|
|
6195
7171
|
console.log(` cwd: ${r.sessionDir}`);
|
|
6196
|
-
console.log("Starting
|
|
6197
|
-
const
|
|
6198
|
-
console.log(` task: id=${
|
|
6199
|
-
console.log("
|
|
6200
|
-
const
|
|
6201
|
-
|
|
6202
|
-
|
|
7172
|
+
console.log("Starting channel 1 (judge)...");
|
|
7173
|
+
const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
|
|
7174
|
+
console.log(` task: id=${t1.id} status=${t1.status}`);
|
|
7175
|
+
console.log("Starting channel 2 (CWE)...");
|
|
7176
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
7177
|
+
console.log(` task: id=${t2.id} status=${t2.status}`);
|
|
7178
|
+
console.log("Waiting for channels (auto-confirming any CC prompts)...");
|
|
7179
|
+
const [ready1, ready2] = await Promise.all([
|
|
7180
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
|
|
7181
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
7182
|
+
]);
|
|
7183
|
+
if (ready1) console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
7184
|
+
else console.warn(` \u26A0 channel 1 did not come up within 60s \u2014 check \`synkro local-cc logs\``);
|
|
7185
|
+
if (ready2) console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
|
|
7186
|
+
else console.warn(` \u26A0 channel 2 (CWE) did not come up within 60s`);
|
|
6203
7187
|
console.log("Updating inference settings...");
|
|
6204
7188
|
await setServerGradingProvider("claude-code");
|
|
6205
7189
|
updateLocalInferenceFlag2(true);
|
|
@@ -6211,25 +7195,58 @@ async function cmdDisable() {
|
|
|
6211
7195
|
updateLocalInferenceFlag2(false);
|
|
6212
7196
|
console.log("Grading provider cleared (remote inference restored). Pueue task left running \u2014 use `synkro local-cc stop` to terminate.");
|
|
6213
7197
|
}
|
|
7198
|
+
async function warmChannels(ready1, ready2) {
|
|
7199
|
+
const warmups = [];
|
|
7200
|
+
if (ready1) {
|
|
7201
|
+
warmups.push(
|
|
7202
|
+
submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 }).then(() => console.log(" channel 1 warm.")).catch(() => console.log(" channel 1 warmup skipped (non-fatal)."))
|
|
7203
|
+
);
|
|
7204
|
+
}
|
|
7205
|
+
if (ready2) {
|
|
7206
|
+
warmups.push(
|
|
7207
|
+
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(() => console.log(" channel 2 warm.")).catch(() => console.log(" channel 2 warmup skipped (non-fatal)."))
|
|
7208
|
+
);
|
|
7209
|
+
}
|
|
7210
|
+
if (warmups.length) {
|
|
7211
|
+
console.log("Warming up inference...");
|
|
7212
|
+
await Promise.all(warmups);
|
|
7213
|
+
}
|
|
7214
|
+
}
|
|
6214
7215
|
async function cmdStart() {
|
|
6215
7216
|
assertClaudeInstalled();
|
|
6216
7217
|
assertPueueInstalled();
|
|
6217
7218
|
assertTmuxInstalled();
|
|
6218
|
-
const
|
|
6219
|
-
console.log(`
|
|
6220
|
-
const
|
|
6221
|
-
console.log(
|
|
7219
|
+
const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
|
|
7220
|
+
console.log(`Channel 1 (judge): id=${t1.id} status=${t1.status}`);
|
|
7221
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
7222
|
+
console.log(`Channel 2 (CWE): id=${t2.id} status=${t2.status}`);
|
|
7223
|
+
const [ready1, ready2] = await Promise.all([
|
|
7224
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
|
|
7225
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
7226
|
+
]);
|
|
7227
|
+
console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
|
|
7228
|
+
console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
|
|
7229
|
+
await warmChannels(ready1, ready2);
|
|
6222
7230
|
}
|
|
6223
7231
|
function cmdStop() {
|
|
6224
|
-
stopTask();
|
|
6225
|
-
|
|
7232
|
+
stopTask(CHANNEL_PRIMARY);
|
|
7233
|
+
stopTask(CHANNEL_SECONDARY);
|
|
7234
|
+
console.log("Both channels stopped.");
|
|
6226
7235
|
}
|
|
6227
7236
|
async function cmdRestart() {
|
|
6228
|
-
stopTask();
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
const
|
|
6232
|
-
console.log(
|
|
7237
|
+
stopTask(CHANNEL_PRIMARY);
|
|
7238
|
+
stopTask(CHANNEL_SECONDARY);
|
|
7239
|
+
const t1 = startTask({ channel: CHANNEL_PRIMARY });
|
|
7240
|
+
const t2 = startTask({ channel: CHANNEL_SECONDARY });
|
|
7241
|
+
console.log(`Channel 1 restarted: id=${t1.id} status=${t1.status}`);
|
|
7242
|
+
console.log(`Channel 2 restarted: id=${t2.id} status=${t2.status}`);
|
|
7243
|
+
const [ready1, ready2] = await Promise.all([
|
|
7244
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
|
|
7245
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
7246
|
+
]);
|
|
7247
|
+
console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
|
|
7248
|
+
console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
|
|
7249
|
+
await warmChannels(ready1, ready2);
|
|
6233
7250
|
}
|
|
6234
7251
|
function relativeTime(iso) {
|
|
6235
7252
|
const ts = new Date(iso).getTime();
|
|
@@ -6445,8 +7462,9 @@ async function gradeCommand(args2) {
|
|
|
6445
7462
|
if (mode === "edit") role = "grade-edit";
|
|
6446
7463
|
else if (mode === "bash") role = "grade-bash";
|
|
6447
7464
|
else if (mode === "plan") role = "grade-plan";
|
|
7465
|
+
else if (mode === "cwe") role = "grade-cwe";
|
|
6448
7466
|
else {
|
|
6449
|
-
console.error("Usage: synkro grade <edit|bash|plan>");
|
|
7467
|
+
console.error("Usage: synkro grade <edit|bash|plan|cwe>");
|
|
6450
7468
|
process.exit(2);
|
|
6451
7469
|
}
|
|
6452
7470
|
const payload = await readStdin();
|