@synkro-sh/cli 1.6.56 → 1.6.58
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 +489 -47
- package/dist/bootstrap.js.map +1 -1
- package/package.json +15 -16
package/dist/bootstrap.js
CHANGED
|
@@ -440,6 +440,7 @@ function installCursorHooks(hooksJsonPath, config) {
|
|
|
440
440
|
timeout: 10,
|
|
441
441
|
matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command|delete_file"
|
|
442
442
|
});
|
|
443
|
+
pushCcHook(h, "beforeMCPExecution", config.mcpGateScriptPath, { timeout: 30 });
|
|
443
444
|
h.afterAgentThought = h.afterAgentThought ?? [];
|
|
444
445
|
h.afterAgentThought.push({
|
|
445
446
|
command: bunRunCmd(config.agentCaptureScriptPath),
|
|
@@ -497,6 +498,7 @@ var init_cursorHookConfig = __esm({
|
|
|
497
498
|
"preToolUse",
|
|
498
499
|
"afterFileEdit",
|
|
499
500
|
"postToolUse",
|
|
501
|
+
"beforeMCPExecution",
|
|
500
502
|
"afterAgentThought",
|
|
501
503
|
"afterAgentResponse"
|
|
502
504
|
];
|
|
@@ -7514,7 +7516,7 @@ async function runClaudeDesktopTap() {
|
|
|
7514
7516
|
await new Promise((resolve4) => {
|
|
7515
7517
|
const child = spawn("bash", [runnerPath], {
|
|
7516
7518
|
stdio: "inherit",
|
|
7517
|
-
env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH }
|
|
7519
|
+
env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_SCAN_TURN_URL: SCAN_TURN_URL, SYNKRO_DLP_POLICY_URL: DLP_POLICY_URL, SYNKRO_TURN_VERDICTS_URL: TURN_VERDICTS_URL, SYNKRO_TURN_VERDICT_URL: TURN_VERDICT_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH }
|
|
7518
7520
|
});
|
|
7519
7521
|
const forward = (sig) => {
|
|
7520
7522
|
try {
|
|
@@ -7531,19 +7533,32 @@ async function runClaudeDesktopTap() {
|
|
|
7531
7533
|
});
|
|
7532
7534
|
});
|
|
7533
7535
|
}
|
|
7534
|
-
var CAPTURE_URL, SCAN_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
|
|
7536
|
+
var CAPTURE_URL, SCAN_URL, SCAN_TURN_URL, DLP_POLICY_URL, TURN_VERDICTS_URL, TURN_VERDICT_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
|
|
7535
7537
|
var init_claudeDesktopTap = __esm({
|
|
7536
7538
|
"cli/installer/claudeDesktopTap.ts"() {
|
|
7537
7539
|
"use strict";
|
|
7538
7540
|
CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
|
|
7539
7541
|
SCAN_URL = "http://127.0.0.1:18931/api/local/scan-upload";
|
|
7542
|
+
SCAN_TURN_URL = "http://127.0.0.1:18931/api/local/scan-turn";
|
|
7543
|
+
DLP_POLICY_URL = "http://127.0.0.1:18931/api/local/dlp-policy";
|
|
7544
|
+
TURN_VERDICTS_URL = "http://127.0.0.1:18931/api/local/turn-verdicts";
|
|
7545
|
+
TURN_VERDICT_URL = "http://127.0.0.1:18931/api/local/turn-verdict";
|
|
7540
7546
|
JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
|
|
7541
|
-
ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64
|
|
7547
|
+
ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64, time, hashlib
|
|
7542
7548
|
from mitmproxy import http
|
|
7543
7549
|
|
|
7544
|
-
|
|
7550
|
+
def _safe_url(u):
|
|
7551
|
+
# CWE-918: these URLs come from the environment the installer sets \u2014 accept ONLY
|
|
7552
|
+
# the loopback container endpoints, never an arbitrary host from a tampered env.
|
|
7553
|
+
return u if (u.startswith("http://127.0.0.1:") or u.startswith("http://localhost:")) else ""
|
|
7554
|
+
|
|
7555
|
+
CAPTURE_URL = _safe_url(os.environ.get("SYNKRO_CAPTURE_URL", ""))
|
|
7545
7556
|
TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
|
|
7546
|
-
SCAN_URL = os.environ.get("SYNKRO_SCAN_URL", "")
|
|
7557
|
+
SCAN_URL = _safe_url(os.environ.get("SYNKRO_SCAN_URL", ""))
|
|
7558
|
+
SCAN_TURN_URL = _safe_url(os.environ.get("SYNKRO_SCAN_TURN_URL", ""))
|
|
7559
|
+
POLICY_URL = _safe_url(os.environ.get("SYNKRO_DLP_POLICY_URL", ""))
|
|
7560
|
+
TURN_VERDICTS_URL = _safe_url(os.environ.get("SYNKRO_TURN_VERDICTS_URL", ""))
|
|
7561
|
+
TURN_VERDICT_URL = _safe_url(os.environ.get("SYNKRO_TURN_VERDICT_URL", ""))
|
|
7547
7562
|
# Token file path \u2014 the SERVER_TOKEN rotates whenever the container is recreated,
|
|
7548
7563
|
# so we re-read it from disk on a 401 instead of needing a tap restart.
|
|
7549
7564
|
TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "")
|
|
@@ -7609,6 +7624,422 @@ def _fmt_tool_name(raw):
|
|
|
7609
7624
|
pass
|
|
7610
7625
|
return raw
|
|
7611
7626
|
|
|
7627
|
+
# \u2500\u2500 M3 live-hold state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
7628
|
+
# Whether to HOLD connector turns (buffer the live completion until graded).
|
|
7629
|
+
# Fetched once from the server: True only when a BLOCK-mode rule exists (+ not
|
|
7630
|
+
# silent). Audit-only orgs get False \u2192 stream + async audit, ZERO added latency.
|
|
7631
|
+
_dlp_hold = None
|
|
7632
|
+
_dlp_lock = threading.Lock()
|
|
7633
|
+
# Shown live in place of a held connector turn while the single grader (the tree
|
|
7634
|
+
# GET) runs; replaced by the real content (if clean) or the block notice.
|
|
7635
|
+
REVIEWING_NOTICE = "\\U0001F6E1\\uFE0F Synkro is reviewing this response for policy compliance\\u2026"
|
|
7636
|
+
|
|
7637
|
+
def _should_hold():
|
|
7638
|
+
global _dlp_hold
|
|
7639
|
+
with _dlp_lock:
|
|
7640
|
+
if _dlp_hold is not None:
|
|
7641
|
+
return _dlp_hold
|
|
7642
|
+
val = False
|
|
7643
|
+
if POLICY_URL:
|
|
7644
|
+
try:
|
|
7645
|
+
with urllib.request.urlopen(urllib.request.Request(POLICY_URL), timeout=4) as resp:
|
|
7646
|
+
val = bool(json.loads(resp.read().decode("utf-8", "replace")).get("hold"))
|
|
7647
|
+
except Exception:
|
|
7648
|
+
val = False
|
|
7649
|
+
with _dlp_lock:
|
|
7650
|
+
_dlp_hold = val
|
|
7651
|
+
return val
|
|
7652
|
+
|
|
7653
|
+
def _scan_turn_call(cid, msgs, connector):
|
|
7654
|
+
# Synchronous DLP grade. Returns the block reason (str) on BLOCK, else None.
|
|
7655
|
+
# Fail-OPEN \u2014 a scan error never blocks the turn.
|
|
7656
|
+
if not (SCAN_TURN_URL and TOKEN): return None
|
|
7657
|
+
try:
|
|
7658
|
+
vb = _post_json(SCAN_TURN_URL, json.dumps({"conversation_id": str(cid)[:80], "messages": msgs, "connector": connector}).encode(), 18)
|
|
7659
|
+
vd = json.loads((vb or b"{}").decode("utf-8", "replace"))
|
|
7660
|
+
if vd.get("block"):
|
|
7661
|
+
return str(vd.get("reason", "restricted data"))[:300]
|
|
7662
|
+
except Exception as e:
|
|
7663
|
+
print("[synkro] turn scan error (fail-open): " + str(e))
|
|
7664
|
+
return None
|
|
7665
|
+
|
|
7666
|
+
def _scan_sse_line(line, st):
|
|
7667
|
+
# Extract governance metadata (usage window + web-search sources/citations)
|
|
7668
|
+
# from one completion SSE line. Shared by the streaming + buffered paths.
|
|
7669
|
+
if st["usage"] is None:
|
|
7670
|
+
um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
|
|
7671
|
+
if um:
|
|
7672
|
+
st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
|
|
7673
|
+
if len(st["sources"]) < 50:
|
|
7674
|
+
for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
|
|
7675
|
+
d = sm.group(1)
|
|
7676
|
+
if d and d not in st["seen"] and len(st["sources"]) < 50:
|
|
7677
|
+
st["seen"].add(d)
|
|
7678
|
+
st["sources"].append({"domain": d, "name": sm.group(2)})
|
|
7679
|
+
if "citation_start_delta" in line and line.startswith("data: ") and len(st["citations"]) < 50:
|
|
7680
|
+
try:
|
|
7681
|
+
cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
|
|
7682
|
+
url = cit.get("url", "")
|
|
7683
|
+
if url and url not in st["cseen"]:
|
|
7684
|
+
st["cseen"].add(url)
|
|
7685
|
+
md = cit.get("metadata") or {}
|
|
7686
|
+
st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
|
|
7687
|
+
except Exception:
|
|
7688
|
+
pass
|
|
7689
|
+
|
|
7690
|
+
def _new_scan_state():
|
|
7691
|
+
return {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
|
|
7692
|
+
|
|
7693
|
+
def _extract_sse_text(body):
|
|
7694
|
+
# Pull all visible text out of a buffered completion SSE (assistant text +
|
|
7695
|
+
# tool_result content) for grading, and detect whether a tool/connector ran.
|
|
7696
|
+
parts = []
|
|
7697
|
+
tool = ("tool_use" in body) or ("tool_result" in body)
|
|
7698
|
+
total = 0
|
|
7699
|
+
for line in body.split("\\n"):
|
|
7700
|
+
if total > 2000000: break
|
|
7701
|
+
if not line.startswith("data: "): continue
|
|
7702
|
+
try: obj = json.loads(line[6:])
|
|
7703
|
+
except Exception: continue
|
|
7704
|
+
d = obj.get("delta")
|
|
7705
|
+
if isinstance(d, dict) and isinstance(d.get("text"), str):
|
|
7706
|
+
parts.append(d["text"]); total += len(d["text"])
|
|
7707
|
+
cb = obj.get("content_block")
|
|
7708
|
+
if isinstance(cb, dict):
|
|
7709
|
+
if isinstance(cb.get("text"), str): parts.append(cb["text"]); total += len(cb["text"])
|
|
7710
|
+
c = cb.get("content")
|
|
7711
|
+
if isinstance(c, str): parts.append(c); total += len(c)
|
|
7712
|
+
elif isinstance(c, list):
|
|
7713
|
+
for x in c:
|
|
7714
|
+
if isinstance(x, dict) and isinstance(x.get("text"), str): parts.append(x["text"]); total += len(x["text"])
|
|
7715
|
+
return ("".join(parts)[:2000000], tool)
|
|
7716
|
+
|
|
7717
|
+
def _neutralize_body(body, notice):
|
|
7718
|
+
# Buffered BLOCK render of a COMPLETE response: replace the answer with the notice
|
|
7719
|
+
# (first text delta, rest blanked), blank text/content content_blocks (incl. the
|
|
7720
|
+
# retrieved doc), leave tool_use + thinking + every structural event intact (the
|
|
7721
|
+
# tree GET scrubs thinking durably). Rewriting a WHOLE buffered body \u2014 not the
|
|
7722
|
+
# live stream \u2014 is what avoids the "couldn't finish" fragility.
|
|
7723
|
+
out = []
|
|
7724
|
+
injected = [False]
|
|
7725
|
+
for line in body.split("\\n"):
|
|
7726
|
+
if not line.startswith("data: "):
|
|
7727
|
+
out.append(line); continue
|
|
7728
|
+
try: obj = json.loads(line[6:])
|
|
7729
|
+
except Exception:
|
|
7730
|
+
out.append(line); continue
|
|
7731
|
+
changed = False
|
|
7732
|
+
d = obj.get("delta")
|
|
7733
|
+
if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
|
|
7734
|
+
d["text"] = (notice if not injected[0] else "")
|
|
7735
|
+
injected[0] = True; changed = True
|
|
7736
|
+
cb = obj.get("content_block")
|
|
7737
|
+
if isinstance(cb, dict):
|
|
7738
|
+
if isinstance(cb.get("text"), str) and cb["text"]:
|
|
7739
|
+
cb["text"] = ""; changed = True
|
|
7740
|
+
if cb.get("content"):
|
|
7741
|
+
cb["content"] = ""; changed = True
|
|
7742
|
+
out.append(("data: " + json.dumps(obj)) if changed else line)
|
|
7743
|
+
return "\\n".join(out)
|
|
7744
|
+
|
|
7745
|
+
# Per-conversation DLP verdict cached by the held completion so the tree GET reuses
|
|
7746
|
+
# it (durable scrub) instead of re-grading. Bounded (CWE-400), lock-guarded (CWE-362).
|
|
7747
|
+
_turn_verdicts = {}
|
|
7748
|
+
_tv_lock = threading.Lock()
|
|
7749
|
+
|
|
7750
|
+
def _cache_turn_verdict(cid, verdict):
|
|
7751
|
+
if not cid: return
|
|
7752
|
+
with _tv_lock:
|
|
7753
|
+
if len(_turn_verdicts) > 200: _turn_verdicts.clear()
|
|
7754
|
+
_turn_verdicts[cid] = verdict
|
|
7755
|
+
|
|
7756
|
+
def _pop_turn_verdict(cid):
|
|
7757
|
+
with _tv_lock:
|
|
7758
|
+
return _turn_verdicts.pop(cid, None)
|
|
7759
|
+
|
|
7760
|
+
# \u2500\u2500 Per-turn durable verdict helpers (PGLite-backed via local endpoints) \u2500\u2500\u2500\u2500\u2500\u2500
|
|
7761
|
+
def _msg_blocks(msg):
|
|
7762
|
+
b = msg.get("content", [])
|
|
7763
|
+
return b if isinstance(b, list) else []
|
|
7764
|
+
|
|
7765
|
+
def _msg_key(msg):
|
|
7766
|
+
# Stable per-turn id. Prefer the message uuid; fall back to a content hash so the
|
|
7767
|
+
# verdict still keys consistently across reopens even if uuid is ever absent.
|
|
7768
|
+
u = msg.get("uuid") or msg.get("id")
|
|
7769
|
+
if isinstance(u, str) and u:
|
|
7770
|
+
return u
|
|
7771
|
+
try:
|
|
7772
|
+
raw = json.dumps(msg.get("content", ""), sort_keys=True).encode("utf-8", "replace")
|
|
7773
|
+
return "h:" + hashlib.sha256(raw).hexdigest()[:40]
|
|
7774
|
+
except Exception:
|
|
7775
|
+
return ""
|
|
7776
|
+
|
|
7777
|
+
def _msg_is_connector(msg):
|
|
7778
|
+
# A turn that pulled content via a connector \u2192 has a tool_result block (the doc).
|
|
7779
|
+
for b in _msg_blocks(msg):
|
|
7780
|
+
if isinstance(b, dict) and (b.get("type", "") == "tool_result" or b.get("type", "").endswith("_tool_result")):
|
|
7781
|
+
return True
|
|
7782
|
+
return False
|
|
7783
|
+
|
|
7784
|
+
def _msg_grade_payload(msg):
|
|
7785
|
+
# Extract (assistant_text, tool_result_text, connector) from a message's blocks.
|
|
7786
|
+
texts = []; tools = []; connector = ""
|
|
7787
|
+
for b in _msg_blocks(msg):
|
|
7788
|
+
if isinstance(b, str):
|
|
7789
|
+
texts.append(b); continue
|
|
7790
|
+
if not isinstance(b, dict):
|
|
7791
|
+
continue
|
|
7792
|
+
bt = b.get("type", "")
|
|
7793
|
+
if bt == "text":
|
|
7794
|
+
texts.append(b.get("text", "") or "")
|
|
7795
|
+
elif bt == "tool_use" or bt == "server_tool_use":
|
|
7796
|
+
nm = b.get("name", "") or ""
|
|
7797
|
+
if nm and not connector:
|
|
7798
|
+
connector = nm.split(":")[0] if ":" in nm else nm
|
|
7799
|
+
elif bt == "tool_result" or bt.endswith("_tool_result"):
|
|
7800
|
+
rc = b.get("content", "")
|
|
7801
|
+
if isinstance(rc, list):
|
|
7802
|
+
for x in rc:
|
|
7803
|
+
if isinstance(x, dict):
|
|
7804
|
+
if isinstance(x.get("text"), str): tools.append(x["text"])
|
|
7805
|
+
elif x.get("content"): tools.append(str(x.get("content")))
|
|
7806
|
+
elif isinstance(rc, str):
|
|
7807
|
+
tools.append(rc)
|
|
7808
|
+
ic = b.get("integration_name") or b.get("name") or ""
|
|
7809
|
+
if ic and not connector:
|
|
7810
|
+
connector = ic
|
|
7811
|
+
return ("\\n".join(texts), "\\n".join(tools), connector)
|
|
7812
|
+
|
|
7813
|
+
def _get_turn_verdicts(uuids):
|
|
7814
|
+
# Batch lookup of durable verdicts from PGLite (one round-trip). {uuid: {blocked,reason}}.
|
|
7815
|
+
if not (TURN_VERDICTS_URL and TOKEN and uuids):
|
|
7816
|
+
return {}
|
|
7817
|
+
try:
|
|
7818
|
+
resp = _post_json(TURN_VERDICTS_URL, json.dumps({"uuids": uuids[:500]}).encode("utf-8"), 8)
|
|
7819
|
+
if resp:
|
|
7820
|
+
o = json.loads(resp.decode("utf-8", "replace"))
|
|
7821
|
+
v = o.get("verdicts")
|
|
7822
|
+
return v if isinstance(v, dict) else {}
|
|
7823
|
+
except Exception as e:
|
|
7824
|
+
print("[synkro] verdict lookup error: " + str(e))
|
|
7825
|
+
return {}
|
|
7826
|
+
|
|
7827
|
+
def _put_turn_verdict(uuid, convo_id, blocked, reason):
|
|
7828
|
+
# Persist one graded turn (fire-and-forget, off the tree-GET critical path).
|
|
7829
|
+
if not (TURN_VERDICT_URL and TOKEN and uuid):
|
|
7830
|
+
return
|
|
7831
|
+
def _send():
|
|
7832
|
+
try:
|
|
7833
|
+
_post_json(TURN_VERDICT_URL, json.dumps({"message_uuid": uuid, "convo_id": convo_id, "blocked": bool(blocked), "reason": reason or ""}).encode("utf-8"), 8)
|
|
7834
|
+
except Exception as e:
|
|
7835
|
+
print("[synkro] verdict persist error: " + str(e))
|
|
7836
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
7837
|
+
|
|
7838
|
+
def _intent_before(all_msgs, idx):
|
|
7839
|
+
# Most recent human message text before idx \u2014 the turn's user intent for grading.
|
|
7840
|
+
for j in range(idx - 1, -1, -1):
|
|
7841
|
+
if all_msgs[j].get("sender") == "human":
|
|
7842
|
+
for b in _msg_blocks(all_msgs[j]):
|
|
7843
|
+
if isinstance(b, dict) and b.get("type") == "text": return (b.get("text") or "")[:2000]
|
|
7844
|
+
if isinstance(b, str): return b[:2000]
|
|
7845
|
+
return ""
|
|
7846
|
+
return ""
|
|
7847
|
+
|
|
7848
|
+
def _apply_turn_verdicts(all_msgs, convo_id):
|
|
7849
|
+
# WHOLE-CONVERSATION per-turn DLP scrub. Mutates all_msgs in place: replaces every
|
|
7850
|
+
# BLOCKED assistant turn's content with the notice. Returns True if anything was
|
|
7851
|
+
# blocked. Verdicts are durable (PGLite via the local endpoints); each connector
|
|
7852
|
+
# turn (a tool_result is present) is graded once on a miss then persisted; pure-chat
|
|
7853
|
+
# turns are skipped. Scrubs EVERY blocked turn \u2014 not just the latest \u2014 so a follow-up
|
|
7854
|
+
# can never resurrect an earlier blocked turn. Fail-OPEN.
|
|
7855
|
+
asst_idx = [i for i in range(len(all_msgs)) if all_msgs[i].get("sender") != "human"]
|
|
7856
|
+
keys = {i: _msg_key(all_msgs[i]) for i in asst_idx}
|
|
7857
|
+
ids = [k for k in keys.values() if k]
|
|
7858
|
+
verdicts = _get_turn_verdicts(ids) if ids else {}
|
|
7859
|
+
do_grade = _should_hold() and SCAN_TURN_URL and TOKEN
|
|
7860
|
+
any_blocked = False
|
|
7861
|
+
for i in asst_idx:
|
|
7862
|
+
key = keys.get(i, "")
|
|
7863
|
+
v = verdicts.get(key) if key else None
|
|
7864
|
+
if v is None and do_grade and key and _msg_is_connector(all_msgs[i]):
|
|
7865
|
+
text, tools, connector = _msg_grade_payload(all_msgs[i])
|
|
7866
|
+
if tools.strip() or text.strip():
|
|
7867
|
+
scan_msgs = [
|
|
7868
|
+
{"role": "user", "content": _intent_before(all_msgs, i), "name": ""},
|
|
7869
|
+
{"role": "tool", "content": tools[:500000], "name": connector[:200]},
|
|
7870
|
+
{"role": "assistant", "content": text[:8000], "name": ""},
|
|
7871
|
+
]
|
|
7872
|
+
reason = _scan_turn_call(convo_id, scan_msgs, connector)
|
|
7873
|
+
v = {"blocked": reason is not None, "reason": reason or ""}
|
|
7874
|
+
_put_turn_verdict(key, convo_id, v["blocked"], v["reason"])
|
|
7875
|
+
if v and v.get("blocked"):
|
|
7876
|
+
all_msgs[i]["content"] = [{"type": "text", "text": "[Blocked by Synkro] " + (v.get("reason") or "restricted data")}]
|
|
7877
|
+
any_blocked = True
|
|
7878
|
+
return any_blocked
|
|
7879
|
+
|
|
7880
|
+
def _feed_answer(chunk, st):
|
|
7881
|
+
# Accumulate the assistant's ANSWER text (text_delta) as the held stream flows,
|
|
7882
|
+
# using a carry for SSE lines split across chunks. The answer reveals the doc's
|
|
7883
|
+
# classification, so once enough has arrived we grade it for an early verdict.
|
|
7884
|
+
txt = st["carry"] + chunk
|
|
7885
|
+
lines = txt.split("\\n")
|
|
7886
|
+
st["carry"] = lines[-1][-100000:] # bound carried partial line (CWE-400)
|
|
7887
|
+
for ln in lines[:-1]:
|
|
7888
|
+
if not ln.startswith("data: "):
|
|
7889
|
+
continue
|
|
7890
|
+
try:
|
|
7891
|
+
obj = json.loads(ln[6:])
|
|
7892
|
+
except Exception:
|
|
7893
|
+
continue
|
|
7894
|
+
d = obj.get("delta")
|
|
7895
|
+
if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
|
|
7896
|
+
if len(st["answer"]) < 200000: # bound (CWE-400)
|
|
7897
|
+
st["answer"] += d["text"]
|
|
7898
|
+
|
|
7899
|
+
def _grade_worker(st, ans):
|
|
7900
|
+
# Runs in a BACKGROUND THREAD (off mitmproxy's event loop). The urllib grade
|
|
7901
|
+
# releases the GIL during I/O, so the loop keeps flushing keep-alive pings.
|
|
7902
|
+
# CWE-362: ans is a snapshot PASSED BY the loop thread (the sole writer of
|
|
7903
|
+
# st["answer"]) at thread-creation \u2014 the worker never reads shared mutable
|
|
7904
|
+
# st["answer"], so there is no data race. It writes only verdict/graded, which the
|
|
7905
|
+
# loop reads AFTER graded.is_set() provides the happens-before barrier.
|
|
7906
|
+
try:
|
|
7907
|
+
ans = ans[:200000]
|
|
7908
|
+
reason = _scan_turn_call(st["cid"], [{"role": "assistant", "content": ans, "name": ""}], "") if ans.strip() else None
|
|
7909
|
+
except Exception:
|
|
7910
|
+
reason = None
|
|
7911
|
+
st["verdict"] = {"block": reason is not None, "reason": reason or ""}
|
|
7912
|
+
_cache_turn_verdict(st["cid"], st["verdict"])
|
|
7913
|
+
st["graded"].set()
|
|
7914
|
+
|
|
7915
|
+
def _classify_frame(fr):
|
|
7916
|
+
# Inspect one CRLF SSE frame's data line \u2192 'tool_use_start' | 'text_start' | 'other'
|
|
7917
|
+
# based on the content_block_start type. Used to find where the FINAL answer begins.
|
|
7918
|
+
for ln in fr.split("\\r\\n"):
|
|
7919
|
+
if ln.startswith("data: "):
|
|
7920
|
+
try:
|
|
7921
|
+
o = json.loads(ln[6:])
|
|
7922
|
+
except Exception:
|
|
7923
|
+
return "other"
|
|
7924
|
+
if o.get("type") == "content_block_start":
|
|
7925
|
+
cb = o.get("content_block")
|
|
7926
|
+
if isinstance(cb, dict):
|
|
7927
|
+
t = cb.get("type")
|
|
7928
|
+
if t == "tool_use":
|
|
7929
|
+
return "tool_use_start"
|
|
7930
|
+
if t == "text":
|
|
7931
|
+
return "text_start"
|
|
7932
|
+
return "other"
|
|
7933
|
+
return "other"
|
|
7934
|
+
|
|
7935
|
+
def _frame_answer_text(fr, st):
|
|
7936
|
+
# Accumulate text_delta text from a HELD frame into st["answer"] (for the grade).
|
|
7937
|
+
for ln in fr.split("\\r\\n"):
|
|
7938
|
+
if ln.startswith("data: "):
|
|
7939
|
+
try:
|
|
7940
|
+
d = json.loads(ln[6:]).get("delta")
|
|
7941
|
+
except Exception:
|
|
7942
|
+
continue
|
|
7943
|
+
if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
|
|
7944
|
+
if len(st["answer"]) < 200000: # CWE-400 bound
|
|
7945
|
+
st["answer"] += d["text"]
|
|
7946
|
+
|
|
7947
|
+
def _block_held(frames, notice):
|
|
7948
|
+
# Frame-aware, CRLF-PRESERVING block render of the held final-answer frames: replace
|
|
7949
|
+
# the answer's text_delta(s) (first\u2192notice, rest\u2192"") and leave every structural frame
|
|
7950
|
+
# (content_block_stop / message_delta / message_stop) intact so the message still
|
|
7951
|
+
# finalizes cleanly. Operates per-frame on \\r\\n lines \u2192 no \\n/\\r\\n mixing, so the
|
|
7952
|
+
# framing stays byte-clean (the thing _neutralize_body's \\n split would have broken).
|
|
7953
|
+
inj = [False]
|
|
7954
|
+
out = []
|
|
7955
|
+
for fr in frames:
|
|
7956
|
+
nl = []
|
|
7957
|
+
for ln in fr.split("\\r\\n"):
|
|
7958
|
+
if ln.startswith("data: "):
|
|
7959
|
+
try:
|
|
7960
|
+
o = json.loads(ln[6:])
|
|
7961
|
+
d = o.get("delta")
|
|
7962
|
+
if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
|
|
7963
|
+
o["delta"]["text"] = notice if not inj[0] else ""
|
|
7964
|
+
inj[0] = True
|
|
7965
|
+
ln = "data: " + json.dumps(o)
|
|
7966
|
+
except Exception:
|
|
7967
|
+
pass
|
|
7968
|
+
nl.append(ln)
|
|
7969
|
+
out.append("\\r\\n".join(nl))
|
|
7970
|
+
return ("\\r\\n\\r\\n".join(out) + "\\r\\n\\r\\n") if out else ""
|
|
7971
|
+
|
|
7972
|
+
def _make_hold_stream(cid):
|
|
7973
|
+
# Pass-through-until-FINAL-ANSWER hold for the claude.ai completion (CRLF SSE; frames
|
|
7974
|
+
# split by \\r\\n\\r\\n). A connector turn is: thinking \u2192 text(preamble) \u2192 tool_use \u2192
|
|
7975
|
+
# tool_result \u2192 \u2026 \u2192 text(ANSWER). Only the FINAL answer is in the user's face, so we
|
|
7976
|
+
# STREAM every earlier frame VERBATIM (natural thinking/search activity + tool chips,
|
|
7977
|
+
# zero mutation) and HOLD only the final answer \u2014 the text block that opens AFTER a
|
|
7978
|
+
# tool_use \u2014 through message_stop. Buffer it (no leak), grade off-thread, heartbeat to
|
|
7979
|
+
# keep Desktop alive, then emit the real answer (clean) or a frame-aware block rewrite
|
|
7980
|
+
# (restricted). CWE-362: the grade thread gets an immutable answer snapshot; the
|
|
7981
|
+
# verdict returns via the Event barrier; the loop is the sole writer of held/answer.
|
|
7982
|
+
st = {"cid": cid, "carry": "", "holding": False, "seen_tool": False,
|
|
7983
|
+
"held": [], "held_size": 0, "answer": "", "started": False, "meta": _new_scan_state(),
|
|
7984
|
+
"graded": threading.Event(), "verdict": {"block": False, "reason": ""}}
|
|
7985
|
+
def _hold(data, _st=st):
|
|
7986
|
+
if data:
|
|
7987
|
+
txt = _st["carry"] + data.decode("utf-8", "replace")
|
|
7988
|
+
frames = txt.split("\\r\\n\\r\\n")
|
|
7989
|
+
_st["carry"] = frames[-1][-200000:] # CWE-400 bounded carry (last partial frame)
|
|
7990
|
+
out = []
|
|
7991
|
+
for fr in frames[:-1]:
|
|
7992
|
+
for ln in fr.split("\\r\\n"):
|
|
7993
|
+
_scan_sse_line(ln, _st["meta"]) # governance metadata, all frames
|
|
7994
|
+
if not _st["holding"]:
|
|
7995
|
+
k = _classify_frame(fr)
|
|
7996
|
+
if k == "tool_use_start":
|
|
7997
|
+
_st["seen_tool"] = True
|
|
7998
|
+
if k == "text_start" and _st["seen_tool"]:
|
|
7999
|
+
_st["holding"] = True # the FINAL answer begins here \u2192 hold from now
|
|
8000
|
+
else:
|
|
8001
|
+
out.append(fr) # thinking / preamble / tool_use / tool_result \u2192 verbatim
|
|
8002
|
+
continue
|
|
8003
|
+
if _st["held_size"] < 8000000: # CWE-400 bound before append
|
|
8004
|
+
_st["held"].append(fr); _st["held_size"] += len(fr)
|
|
8005
|
+
_frame_answer_text(fr, _st)
|
|
8006
|
+
if _st["holding"] and not _st["started"] and len(_st["answer"]) >= 1200:
|
|
8007
|
+
_st["started"] = True
|
|
8008
|
+
threading.Thread(target=_grade_worker, args=(_st, _st["answer"][:200000]), daemon=True).start()
|
|
8009
|
+
if out:
|
|
8010
|
+
return ("\\r\\n\\r\\n".join(out) + "\\r\\n\\r\\n").encode("utf-8") # stream activity verbatim
|
|
8011
|
+
return b": synkro\\n\\n" # answer held \u2192 heartbeat (keeps Desktop alive, PROVEN)
|
|
8012
|
+
# upstream ended
|
|
8013
|
+
m = _st["meta"]
|
|
8014
|
+
if (m["usage"] or m["sources"] or m["citations"]) and _st["cid"]:
|
|
8015
|
+
with _sse_lock:
|
|
8016
|
+
_sse_meta[_st["cid"]] = {"usage": m["usage"], "sources": m["sources"], "citations": m["citations"]}
|
|
8017
|
+
if not _st["holding"]: # no final-answer block detected \u2014 everything already streamed
|
|
8018
|
+
return _st["carry"].encode("utf-8") if _st["carry"] else b""
|
|
8019
|
+
if _st["carry"]:
|
|
8020
|
+
_st["held"].append(_st["carry"]); _frame_answer_text(_st["carry"], _st); _st["carry"] = ""
|
|
8021
|
+
if not _st["started"] and _st["answer"].strip(): # short answer never hit the 1200 trigger
|
|
8022
|
+
_st["started"] = True
|
|
8023
|
+
threading.Thread(target=_grade_worker, args=(_st, _st["answer"][:200000]), daemon=True).start()
|
|
8024
|
+
held_frames = list(_st["held"])
|
|
8025
|
+
def _grade_window(_st2=_st, _frames=held_frames):
|
|
8026
|
+
waited = 0.0
|
|
8027
|
+
while not _st2["graded"].is_set() and waited < 60.0:
|
|
8028
|
+
yield b": synkro\\n\\n" # heartbeat WHILE grading \u2014 keeps Desktop alive (PROVEN)
|
|
8029
|
+
time.sleep(0.5)
|
|
8030
|
+
waited += 0.5
|
|
8031
|
+
v = _st2["verdict"]
|
|
8032
|
+
if _st2["graded"].is_set() and v["block"]:
|
|
8033
|
+
print("[synkro] HELD+BLOCKED live (reason=" + v["reason"][:60] + ")")
|
|
8034
|
+
yield _block_held(_frames, "[Blocked by Synkro] " + v["reason"]).encode("utf-8")
|
|
8035
|
+
elif _st2["graded"].is_set():
|
|
8036
|
+
yield ("\\r\\n\\r\\n".join(_frames) + "\\r\\n\\r\\n").encode("utf-8") # clean \u2192 real answer
|
|
8037
|
+
else: # grade never landed in 60s \u2014 never leak; tree GET backstop finalizes
|
|
8038
|
+
print("[synkro] hold: grade timeout \u2192 placeholder + tree-GET backstop")
|
|
8039
|
+
yield _block_held(_frames, "[Synkro is reviewing this response\u2026]").encode("utf-8")
|
|
8040
|
+
return _grade_window()
|
|
8041
|
+
return _hold
|
|
8042
|
+
|
|
7612
8043
|
class SynkroTap:
|
|
7613
8044
|
def request(self, flow):
|
|
7614
8045
|
# \u2500\u2500 Upload DLP gate \u2500\u2500 Scan file uploads BEFORE they reach claude.ai and
|
|
@@ -7662,10 +8093,10 @@ class SynkroTap:
|
|
|
7662
8093
|
print("[synkro] upload scan error (fail-open): " + str(e))
|
|
7663
8094
|
return
|
|
7664
8095
|
if verdict.get("block"):
|
|
7665
|
-
reason = str(verdict.get("reason", "Blocked by Synkro
|
|
8096
|
+
reason = str(verdict.get("reason", "Blocked by Synkro policy"))[:300]
|
|
7666
8097
|
flow.response = http.Response.make(
|
|
7667
8098
|
403,
|
|
7668
|
-
json.dumps({"error": {"type": "
|
|
8099
|
+
json.dumps({"error": {"type": "blocked_by_synkro", "message": "Blocked by Synkro: " + reason}}).encode(),
|
|
7669
8100
|
{"Content-Type": "application/json"},
|
|
7670
8101
|
)
|
|
7671
8102
|
print("[synkro] BLOCKED upload (size=" + str(payload.get("size", 0)) + " rule=" + str(verdict.get("ruleId", "")) + ")")
|
|
@@ -7673,46 +8104,28 @@ class SynkroTap:
|
|
|
7673
8104
|
print("[synkro] upload scanned -> allow (size=" + str(payload.get("size", 0)) + ")")
|
|
7674
8105
|
|
|
7675
8106
|
def responseheaders(self, flow):
|
|
7676
|
-
#
|
|
7677
|
-
#
|
|
7678
|
-
#
|
|
7679
|
-
#
|
|
7680
|
-
#
|
|
8107
|
+
# The answer the user watches TYPE OUT comes from claude.ai's completion \u2014 THAT
|
|
8108
|
+
# is the stream we HOLD (buffer + heartbeat \u2192 no leak, no disconnect; see
|
|
8109
|
+
# _make_hold_stream). a-api.anthropic.com is a background call that doesn't carry
|
|
8110
|
+
# the answer, so it streams through untouched (default below). The tree GET is
|
|
8111
|
+
# buffered for the dashboard + the durable block scrub.
|
|
7681
8112
|
r = flow.request
|
|
7682
|
-
|
|
8113
|
+
host = r.pretty_host or ""
|
|
8114
|
+
if "claude.ai" in host and "chat_conversations" in r.path:
|
|
7683
8115
|
if r.method == "GET" and "tree" in r.pretty_url:
|
|
7684
8116
|
return
|
|
7685
8117
|
if r.method == "POST" and "completion" in r.path:
|
|
7686
8118
|
cm = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
|
|
7687
8119
|
cid = cm.group(1) if cm else ""
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
#
|
|
7691
|
-
#
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
_st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
|
|
7698
|
-
if len(_st["sources"]) < 50:
|
|
7699
|
-
for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
|
|
7700
|
-
d = sm.group(1)
|
|
7701
|
-
if d and d not in _st["seen"] and len(_st["sources"]) < 50:
|
|
7702
|
-
_st["seen"].add(d)
|
|
7703
|
-
_st["sources"].append({"domain": d, "name": sm.group(2)})
|
|
7704
|
-
# Citations: the specific pages the model cited (title + url +
|
|
7705
|
-
# domain) \u2014 the real "tool result" for web search.
|
|
7706
|
-
if "citation_start_delta" in line and line.startswith("data: ") and len(_st["citations"]) < 50:
|
|
7707
|
-
try:
|
|
7708
|
-
cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
|
|
7709
|
-
url = cit.get("url", "")
|
|
7710
|
-
if url and url not in _st["cseen"]:
|
|
7711
|
-
_st["cseen"].add(url)
|
|
7712
|
-
md = cit.get("metadata") or {}
|
|
7713
|
-
_st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
|
|
7714
|
-
except Exception:
|
|
7715
|
-
pass
|
|
8120
|
+
with _mcp_lock:
|
|
8121
|
+
has_servers = len(_mcp_registry) > 0
|
|
8122
|
+
# HOLD when a BLOCK-mode rule exists AND connectors are connected \u2014 buffer
|
|
8123
|
+
# the answer, grade off-thread, heartbeat through the grade, emit the verdict.
|
|
8124
|
+
if has_servers and _should_hold():
|
|
8125
|
+
flow.response.stream = _make_hold_stream(cid)
|
|
8126
|
+
return
|
|
8127
|
+
# Otherwise just TAP for governance metadata (no hold; audit stays async).
|
|
8128
|
+
st = _new_scan_state()
|
|
7716
8129
|
def _tap(data, _st=st, _cid=cid):
|
|
7717
8130
|
if data:
|
|
7718
8131
|
try:
|
|
@@ -7722,9 +8135,9 @@ class SynkroTap:
|
|
|
7722
8135
|
lines = txt.split("\\n")
|
|
7723
8136
|
_st["carry"] = lines[-1][-200000:] # bound the carried partial line (CWE-400)
|
|
7724
8137
|
for ln in lines[:-1]:
|
|
7725
|
-
|
|
8138
|
+
_scan_sse_line(ln, _st)
|
|
7726
8139
|
else: # end of stream \u2014 flush carry + publish
|
|
7727
|
-
|
|
8140
|
+
_scan_sse_line(_st["carry"], _st)
|
|
7728
8141
|
if (_st["usage"] or _st["sources"] or _st["citations"]) and _cid:
|
|
7729
8142
|
with _sse_lock:
|
|
7730
8143
|
_sse_meta[_cid] = {"usage": _st["usage"], "sources": _st["sources"], "citations": _st["citations"]}
|
|
@@ -7838,6 +8251,14 @@ class SynkroTap:
|
|
|
7838
8251
|
messages.append({"role": "user", "content": text})
|
|
7839
8252
|
if not messages: return
|
|
7840
8253
|
data = {"request": {"model": model, "messages": messages}, "conversation_id": convo_id}
|
|
8254
|
+
# PER-TURN key: the current turn's assistant message id (same key used for
|
|
8255
|
+
# dlp_turn_verdicts). Lets the server store each turn as its OWN event so a
|
|
8256
|
+
# blocked turn isn't overwritten/deleted by a later passing turn.
|
|
8257
|
+
turn_id = ""
|
|
8258
|
+
for _m in reversed(chat):
|
|
8259
|
+
if _m.get("sender") != "human":
|
|
8260
|
+
turn_id = _msg_key(_m); break
|
|
8261
|
+
if turn_id: data["turn_id"] = turn_id
|
|
7841
8262
|
meta = {}
|
|
7842
8263
|
if platform: meta["platform"] = platform
|
|
7843
8264
|
if web_search_on: meta["web_search_enabled"] = True
|
|
@@ -7854,6 +8275,17 @@ class SynkroTap:
|
|
|
7854
8275
|
ntools += len(mm.get("tool_calls") or [])
|
|
7855
8276
|
nsrc = len((sm or {}).get("sources") or [])
|
|
7856
8277
|
print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
|
|
8278
|
+
|
|
8279
|
+
# \u2500\u2500 Tier-2 connector DLP gate (WHOLE-CONVERSATION, per-turn, PERSISTENT) \u2500\u2500
|
|
8280
|
+
# Scrub every blocked assistant turn (durable PGLite verdicts; connector turns
|
|
8281
|
+
# graded once on miss). See _apply_turn_verdicts. Fail-OPEN on any error.
|
|
8282
|
+
try:
|
|
8283
|
+
any_blocked = _apply_turn_verdicts(all_msgs, convo_id)
|
|
8284
|
+
except Exception as e:
|
|
8285
|
+
any_blocked = False
|
|
8286
|
+
print("[synkro] turn-verdict scrub error (fail-open): " + str(e))
|
|
8287
|
+
|
|
8288
|
+
# Capture the REAL content for audit regardless of the block decision.
|
|
7857
8289
|
def send(_d=data):
|
|
7858
8290
|
try:
|
|
7859
8291
|
_post_json(CAPTURE_URL, json.dumps(_d).encode(), 5)
|
|
@@ -7861,6 +8293,14 @@ class SynkroTap:
|
|
|
7861
8293
|
print("[synkro] capture error: " + str(e))
|
|
7862
8294
|
threading.Thread(target=send, daemon=True).start()
|
|
7863
8295
|
|
|
8296
|
+
# Re-serialize the tree so the renderer never sees any blocked turn's content.
|
|
8297
|
+
if any_blocked:
|
|
8298
|
+
try:
|
|
8299
|
+
flow.response.text = json.dumps(convo)
|
|
8300
|
+
print("[synkro] BLOCKED turn view (whole-convo per-turn, durable)")
|
|
8301
|
+
except Exception as e:
|
|
8302
|
+
print("[synkro] block scrub failed (fail-open): " + str(e))
|
|
8303
|
+
|
|
7864
8304
|
addons = [SynkroTap()]
|
|
7865
8305
|
`;
|
|
7866
8306
|
MCP_PROXY_PY = `#!/usr/bin/env python3
|
|
@@ -9515,7 +9955,7 @@ function writeConfigEnv(opts) {
|
|
|
9515
9955
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
9516
9956
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
9517
9957
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
9518
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
9958
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.58")}`
|
|
9519
9959
|
];
|
|
9520
9960
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
9521
9961
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -9814,7 +10254,8 @@ async function installCommand(opts = {}) {
|
|
|
9814
10254
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
9815
10255
|
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
9816
10256
|
transcriptSyncScriptPath: scripts.transcriptSyncScript,
|
|
9817
|
-
installScanScriptPath: scripts.installScanScript
|
|
10257
|
+
installScanScriptPath: scripts.installScanScript,
|
|
10258
|
+
mcpGateScriptPath: scripts.mcpGateScript
|
|
9818
10259
|
});
|
|
9819
10260
|
console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
|
|
9820
10261
|
}
|
|
@@ -10298,7 +10739,8 @@ function reconcileHarness() {
|
|
|
10298
10739
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
10299
10740
|
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
10300
10741
|
transcriptSyncScriptPath: scripts.transcriptSyncScript,
|
|
10301
|
-
installScanScriptPath: scripts.installScanScript
|
|
10742
|
+
installScanScriptPath: scripts.installScanScript,
|
|
10743
|
+
mcpGateScriptPath: scripts.mcpGateScript
|
|
10302
10744
|
});
|
|
10303
10745
|
console.log(" \u2713 Cursor hooks registered");
|
|
10304
10746
|
try {
|
|
@@ -12771,7 +13213,7 @@ var args = process.argv.slice(2);
|
|
|
12771
13213
|
var cmd = args[0] || "";
|
|
12772
13214
|
var subArgs = args.slice(1);
|
|
12773
13215
|
function printVersion() {
|
|
12774
|
-
console.log("1.6.
|
|
13216
|
+
console.log("1.6.58");
|
|
12775
13217
|
}
|
|
12776
13218
|
function printHelp2() {
|
|
12777
13219
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|