@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 +353 -93
- package/dist/bootstrap.js.map +1 -1
- package/package.json +15 -16
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
|
-
|
|
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
|
|
7709
|
-
#
|
|
7710
|
-
#
|
|
7711
|
-
#
|
|
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
|
-
#
|
|
7799
|
-
#
|
|
7800
|
-
#
|
|
7801
|
-
#
|
|
7802
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
7813
|
-
#
|
|
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.
|
|
8125
|
+
flow.response.stream = _make_hold_stream(cid)
|
|
7819
8126
|
return
|
|
7820
|
-
#
|
|
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
|
|
7996
|
-
#
|
|
7997
|
-
#
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
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
|
-
#
|
|
8031
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|