@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 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
- CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
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 DLP policy"))[:300]
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": "blocked_by_synkro_dlp", "message": "Blocked by Synkro DLP: " + reason}}).encode(),
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
- # Buffer the small conversation-tree GET (parsed for the dashboard).
7677
- # For the completion POST, TAP the SSE chunk-by-chunk and pass each
7678
- # through unchanged \u2014 Claude Desktop keeps streaming normally while we
7679
- # accumulate a bounded copy for the raw diagnostic dump. Everything else
7680
- # streams untouched.
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
- if "claude.ai" in (r.pretty_host or "") and "chat_conversations" in r.path:
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
- # Incremental parse: scan complete SSE lines for the small governance
7689
- # signals AS THEY STREAM and keep only the extracted results \u2014 never
7690
- # buffer the body. The carry holds at most one partial line (bounded),
7691
- # so there's NO cap on total stream length and no source is dropped.
7692
- st = {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7693
- def _scan(line, _st=st):
7694
- if _st["usage"] is None:
7695
- um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7696
- if um:
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
- _scan(ln)
8138
+ _scan_sse_line(ln, _st)
7726
8139
  else: # end of stream \u2014 flush carry + publish
7727
- _scan(_st["carry"])
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.56")}`
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.56");
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