@synkro-sh/cli 1.6.57 → 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
@@ -7516,7 +7516,7 @@ async function runClaudeDesktopTap() {
7516
7516
  await new Promise((resolve4) => {
7517
7517
  const child = spawn("bash", [runnerPath], {
7518
7518
  stdio: "inherit",
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_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 }
7520
7520
  });
7521
7521
  const forward = (sig) => {
7522
7522
  try {
@@ -7533,7 +7533,7 @@ async function runClaudeDesktopTap() {
7533
7533
  });
7534
7534
  });
7535
7535
  }
7536
- var CAPTURE_URL, SCAN_URL, SCAN_TURN_URL, DLP_POLICY_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;
7537
7537
  var init_claudeDesktopTap = __esm({
7538
7538
  "cli/installer/claudeDesktopTap.ts"() {
7539
7539
  "use strict";
@@ -7541,15 +7541,24 @@ var init_claudeDesktopTap = __esm({
7541
7541
  SCAN_URL = "http://127.0.0.1:18931/api/local/scan-upload";
7542
7542
  SCAN_TURN_URL = "http://127.0.0.1:18931/api/local/scan-turn";
7543
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";
7544
7546
  JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
7545
- 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
7546
7548
  from mitmproxy import http
7547
7549
 
7548
- 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", ""))
7549
7556
  TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7550
- SCAN_URL = os.environ.get("SYNKRO_SCAN_URL", "")
7551
- SCAN_TURN_URL = os.environ.get("SYNKRO_SCAN_TURN_URL", "")
7552
- POLICY_URL = os.environ.get("SYNKRO_DLP_POLICY_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", ""))
7553
7562
  # Token file path \u2014 the SERVER_TOKEN rotates whenever the container is recreated,
7554
7563
  # so we re-read it from disk on a 401 instead of needing a tap restart.
7555
7564
  TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "")
@@ -7705,10 +7714,12 @@ def _extract_sse_text(body):
7705
7714
  if isinstance(x, dict) and isinstance(x.get("text"), str): parts.append(x["text"]); total += len(x["text"])
7706
7715
  return ("".join(parts)[:2000000], tool)
7707
7716
 
7708
- def _neutralize_sse(body, notice):
7709
- # Rewrite a buffered completion SSE: blank every text/content field (inject the
7710
- # notice into the FIRST text delta), leaving all structural events intact so
7711
- # Claude Desktop still renders a valid \u2014 but content-free \u2014 turn.
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.
7712
7723
  out = []
7713
7724
  injected = [False]
7714
7725
  for line in body.split("\\n"):
@@ -7719,7 +7730,7 @@ def _neutralize_sse(body, notice):
7719
7730
  out.append(line); continue
7720
7731
  changed = False
7721
7732
  d = obj.get("delta")
7722
- if isinstance(d, dict) and isinstance(d.get("text"), str):
7733
+ if isinstance(d, dict) and d.get("type") == "text_delta" and isinstance(d.get("text"), str):
7723
7734
  d["text"] = (notice if not injected[0] else "")
7724
7735
  injected[0] = True; changed = True
7725
7736
  cb = obj.get("content_block")
@@ -7731,6 +7742,304 @@ def _neutralize_sse(body, notice):
7731
7742
  out.append(("data: " + json.dumps(obj)) if changed else line)
7732
7743
  return "\\n".join(out)
7733
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
+
7734
8043
  class SynkroTap:
7735
8044
  def request(self, flow):
7736
8045
  # \u2500\u2500 Upload DLP gate \u2500\u2500 Scan file uploads BEFORE they reach claude.ai and
@@ -7795,13 +8104,14 @@ class SynkroTap:
7795
8104
  print("[synkro] upload scanned -> allow (size=" + str(payload.get("size", 0)) + ")")
7796
8105
 
7797
8106
  def responseheaders(self, flow):
7798
- # Buffer the small conversation-tree GET (parsed for the dashboard).
7799
- # For the completion POST, TAP the SSE chunk-by-chunk and pass each
7800
- # through unchanged \u2014 Claude Desktop keeps streaming normally while we
7801
- # accumulate a bounded copy for the raw diagnostic dump. Everything else
7802
- # 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.
7803
8112
  r = flow.request
7804
- 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:
7805
8115
  if r.method == "GET" and "tree" in r.pretty_url:
7806
8116
  return
7807
8117
  if r.method == "POST" and "completion" in r.path:
@@ -7809,16 +8119,12 @@ class SynkroTap:
7809
8119
  cid = cm.group(1) if cm else ""
7810
8120
  with _mcp_lock:
7811
8121
  has_servers = len(_mcp_registry) > 0
7812
- # M3 LIVE HOLD: only when a BLOCK-mode rule exists AND connectors are
7813
- # connected, BUFFER the whole completion (leave .stream unset) so
7814
- # response() can grade it and neutralize restricted content BEFORE the
7815
- # user sees a single token. Audit-only / no-connector turns skip this
7816
- # entirely and stream as before \u2014 zero added latency.
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.
7817
8124
  if has_servers and _should_hold():
7818
- flow.metadata["synkro_hold_cid"] = cid
8125
+ flow.response.stream = _make_hold_stream(cid)
7819
8126
  return
7820
- # Streaming path (unchanged behaviour): tap chunk-by-chunk, extract
7821
- # governance metadata only, never buffer the body. Audit stays async.
8127
+ # Otherwise just TAP for governance metadata (no hold; audit stays async).
7822
8128
  st = _new_scan_state()
7823
8129
  def _tap(data, _st=st, _cid=cid):
7824
8130
  if data:
@@ -7845,36 +8151,6 @@ class SynkroTap:
7845
8151
  def response(self, flow):
7846
8152
  r = flow.request
7847
8153
  if "claude.ai" not in (r.pretty_host or ""): return
7848
- # \u2500\u2500 M3: a HELD completion (buffered for inline grading) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7849
- # We have the full SSE body now. Re-derive governance metadata (same as the
7850
- # streaming path), then \u2014 if the turn used a tool/connector \u2014 grade it
7851
- # SYNCHRONOUSLY and, on a block, NEUTRALIZE the response so the user never
7852
- # sees the restricted content. Verdict is cached for the tree GET to reuse.
7853
- hold_cid = (flow.metadata.get("synkro_hold_cid") if hasattr(flow, "metadata") else None)
7854
- if hold_cid is not None and "completion" in r.path and flow.response:
7855
- try:
7856
- body = flow.response.get_text() or ""
7857
- except Exception:
7858
- body = ""
7859
- st = _new_scan_state()
7860
- for ln in body.split("\\n"):
7861
- _scan_sse_line(ln, st)
7862
- if (st["usage"] or st["sources"] or st["citations"]) and hold_cid:
7863
- with _sse_lock:
7864
- _sse_meta[hold_cid] = {"usage": st["usage"], "sources": st["sources"], "citations": st["citations"]}
7865
- text, has_tool = _extract_sse_text(body)
7866
- if has_tool:
7867
- # The turn used a connector \u2014 HOLD the live view: replace the streamed
7868
- # content with a "reviewing" placeholder so the user never sees the raw
7869
- # response. The tree GET is the SINGLE grader and renders the final
7870
- # state (real content if clean, block notice if restricted) \u2014 so there
7871
- # is exactly one full-document grade per turn (no double-grade race).
7872
- try:
7873
- flow.response.text = _neutralize_sse(body, REVIEWING_NOTICE)
7874
- print("[synkro] HELD completion -> reviewing placeholder")
7875
- except Exception as e:
7876
- print("[synkro] hold placeholder failed (fail-open): " + str(e))
7877
- return
7878
8154
  # MCP server registry from the bootstrap SSE stream.
7879
8155
  if "mcp" in r.path and "bootstrap" in r.path and flow.response:
7880
8156
  try:
@@ -7975,6 +8251,14 @@ class SynkroTap:
7975
8251
  messages.append({"role": "user", "content": text})
7976
8252
  if not messages: return
7977
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
7978
8262
  meta = {}
7979
8263
  if platform: meta["platform"] = platform
7980
8264
  if web_search_on: meta["web_search_enabled"] = True
@@ -7992,32 +8276,14 @@ class SynkroTap:
7992
8276
  nsrc = len((sm or {}).get("sources") or [])
7993
8277
  print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
7994
8278
 
7995
- # \u2500\u2500 Tier-2 connector DLP gate \u2500\u2500 If this turn pulled content via an MCP
7996
- # connector, scan it SYNCHRONOUSLY against blocking org rules. On a block
7997
- # verdict we HOLD this tree GET (the authoritative render) until the verdict
7998
- # and then SCRUB the turn from the tree the renderer sees \u2014 the employee
7999
- # never sees the restricted content. The REAL content is still captured
8000
- # below for audit (record-everything-even-when-blocked). Fail-OPEN.
8001
- blocked_reason = None
8002
- has_connector = any(m.get("role") == "tool" and m.get("content") for m in messages)
8003
- # SINGLE grader: the held completion only shows a "reviewing" placeholder, so
8004
- # the tree GET is the one and only place a turn is graded \u2014 exactly one
8005
- # full-document grade per turn (no double-grade). Gated on a BLOCK-mode rule
8006
- # existing; audit-only orgs (_should_hold False) skip this and stay async.
8007
- if _should_hold() and SCAN_TURN_URL and TOKEN and has_connector:
8008
- # CWE-20/CWE-400: bounded, shape-validated payload (<=200 msgs, <=2MB).
8009
- scan_msgs = []
8010
- budget = 2000000
8011
- for m in messages[:200]:
8012
- if budget <= 0: break
8013
- c = str(m.get("content", ""))[:min(500000, budget)]
8014
- budget -= len(c)
8015
- scan_msgs.append({"role": str(m.get("role", "user"))[:20], "content": c, "name": str(m.get("name", ""))[:200]})
8016
- connector = ""
8017
- for m in scan_msgs:
8018
- if m["role"] == "tool" and m["name"]:
8019
- nm = m["name"]; connector = (nm.split(":")[0] if ":" in nm else nm); break
8020
- blocked_reason = _scan_turn_call(convo_id, scan_msgs, connector)
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))
8021
8287
 
8022
8288
  # Capture the REAL content for audit regardless of the block decision.
8023
8289
  def send(_d=data):
@@ -8027,17 +8293,11 @@ class SynkroTap:
8027
8293
  print("[synkro] capture error: " + str(e))
8028
8294
  threading.Thread(target=send, daemon=True).start()
8029
8295
 
8030
- # Block the rendered view: replace this turn's assistant message content in
8031
- # the tree (everything after the last human turn) with a Synkro notice, so
8032
- # the restricted content + any model output derived from it is never shown.
8033
- if blocked_reason is not None:
8034
- notice = "[Blocked by Synkro] " + blocked_reason
8035
- for i in range(last_human + 1, len(all_msgs)):
8036
- if all_msgs[i].get("sender") != "human":
8037
- all_msgs[i]["content"] = [{"type": "text", "text": notice}]
8296
+ # Re-serialize the tree so the renderer never sees any blocked turn's content.
8297
+ if any_blocked:
8038
8298
  try:
8039
8299
  flow.response.text = json.dumps(convo)
8040
- print("[synkro] BLOCKED turn view (reason=" + blocked_reason[:80] + ")")
8300
+ print("[synkro] BLOCKED turn view (whole-convo per-turn, durable)")
8041
8301
  except Exception as e:
8042
8302
  print("[synkro] block scrub failed (fail-open): " + str(e))
8043
8303
 
@@ -9695,7 +9955,7 @@ function writeConfigEnv(opts) {
9695
9955
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
9696
9956
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
9697
9957
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
9698
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.57")}`
9958
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.58")}`
9699
9959
  ];
9700
9960
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
9701
9961
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -12953,7 +13213,7 @@ var args = process.argv.slice(2);
12953
13213
  var cmd = args[0] || "";
12954
13214
  var subArgs = args.slice(1);
12955
13215
  function printVersion() {
12956
- console.log("1.6.57");
13216
+ console.log("1.6.58");
12957
13217
  }
12958
13218
  function printHelp2() {
12959
13219
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents