@synkro-sh/cli 1.6.55 → 1.6.57

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
@@ -193,6 +193,19 @@ function installCCHooks(settingsPath, config) {
193
193
  ],
194
194
  [SYNKRO_MARKER]: true
195
195
  });
196
+ if (config.mcpGateScriptPath) {
197
+ settings.hooks.PreToolUse.push({
198
+ matcher: "mcp__.*",
199
+ hooks: [
200
+ {
201
+ type: "command",
202
+ command: config.mcpGateScriptPath,
203
+ timeout: 30
204
+ }
205
+ ],
206
+ [SYNKRO_MARKER]: true
207
+ });
208
+ }
196
209
  if (config.taskActivateIntentScriptPath) {
197
210
  settings.hooks.PreToolUse.push({
198
211
  matcher: "mcp__synkro-guardrails__activate_standard",
@@ -427,6 +440,7 @@ function installCursorHooks(hooksJsonPath, config) {
427
440
  timeout: 10,
428
441
  matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command|delete_file"
429
442
  });
443
+ pushCcHook(h, "beforeMCPExecution", config.mcpGateScriptPath, { timeout: 30 });
430
444
  h.afterAgentThought = h.afterAgentThought ?? [];
431
445
  h.afterAgentThought.push({
432
446
  command: bunRunCmd(config.agentCaptureScriptPath),
@@ -484,6 +498,7 @@ var init_cursorHookConfig = __esm({
484
498
  "preToolUse",
485
499
  "afterFileEdit",
486
500
  "postToolUse",
501
+ "beforeMCPExecution",
487
502
  "afterAgentThought",
488
503
  "afterAgentResponse"
489
504
  ];
@@ -820,7 +835,7 @@ synkro_post_with_retry() {
820
835
  function stubHook(surface, optsLiteral) {
821
836
  return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
822
837
  }
823
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
838
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_MCP_GATE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
824
839
  var init_hookScriptsTs = __esm({
825
840
  "cli/installer/hookScriptsTs.ts"() {
826
841
  "use strict";
@@ -6432,6 +6447,7 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6432
6447
  STUB_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true }");
6433
6448
  STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
6434
6449
  STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
6450
+ STUB_MCP_GATE_TS = stubHook("mcp-gate", "{ needsTranscript: true }");
6435
6451
  STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
6436
6452
  STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true }");
6437
6453
  STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
@@ -7364,9 +7380,9 @@ __export(claudeDesktopTap_exports, {
7364
7380
  runClaudeDesktopTap: () => runClaudeDesktopTap
7365
7381
  });
7366
7382
  import { spawn } from "child_process";
7367
- import { writeFileSync as writeFileSync6, mkdtempSync, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
7383
+ import { writeFileSync as writeFileSync6, mkdtempSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
7368
7384
  import { join as join5 } from "path";
7369
- import { tmpdir, homedir as homedir5 } from "os";
7385
+ import { homedir as homedir5 } from "os";
7370
7386
  function claudeDesktopInstalled() {
7371
7387
  return process.platform === "darwin" && existsSync7("/Applications/Claude.app/Contents/MacOS/Claude");
7372
7388
  }
@@ -7451,7 +7467,7 @@ fi
7451
7467
  echo "Starting proxy on :$PROXY_PORT..."
7452
7468
  # stdin from /dev/null so mitmdump can't grab the TTY in raw mode \u2014 otherwise it
7453
7469
  # captures keypresses (incl. Ctrl+C) instead of letting them become a signal.
7454
- mitmdump --set confdir="$MITM_CA_DIR" --listen-port $PROXY_PORT --mode regular -s "$TAP_SCRIPT" </dev/null &>/tmp/synkro-mitm.log &
7470
+ mitmdump --set confdir="$MITM_CA_DIR" --listen-port $PROXY_PORT --mode regular -s "$TAP_SCRIPT" </dev/null &>"$(dirname "$TAP_SCRIPT")/mitm.log" &
7455
7471
  MITM_PID=$!
7456
7472
  sleep 2
7457
7473
 
@@ -7489,7 +7505,9 @@ async function runClaudeDesktopTap() {
7489
7505
  console.log(" Could not read local auth token (~/.synkro/.mcp-jwt). Run `synkro install` first.");
7490
7506
  return;
7491
7507
  }
7492
- const sessionDir = mkdtempSync(join5(tmpdir(), "synkro-cd-"));
7508
+ const cdRoot = join5(homedir5(), ".synkro", "cd-sessions");
7509
+ mkdirSync6(cdRoot, { recursive: true });
7510
+ const sessionDir = mkdtempSync(join5(cdRoot, "synkro-cd-"));
7493
7511
  writeFileSync6(join5(sessionDir, "tap.py"), ADDON_PY, "utf-8");
7494
7512
  writeFileSync6(join5(sessionDir, "mcp_proxy.py"), MCP_PROXY_PY, { mode: 493 });
7495
7513
  writeFileSync6(join5(sessionDir, "mcp_patch.py"), MCP_PATCH_PY, "utf-8");
@@ -7498,7 +7516,7 @@ async function runClaudeDesktopTap() {
7498
7516
  await new Promise((resolve4) => {
7499
7517
  const child = spawn("bash", [runnerPath], {
7500
7518
  stdio: "inherit",
7501
- env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_TAP_TOKEN: token }
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 }
7502
7520
  });
7503
7521
  const forward = (sig) => {
7504
7522
  try {
@@ -7515,17 +7533,55 @@ async function runClaudeDesktopTap() {
7515
7533
  });
7516
7534
  });
7517
7535
  }
7518
- var CAPTURE_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7536
+ var CAPTURE_URL, SCAN_URL, SCAN_TURN_URL, DLP_POLICY_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7519
7537
  var init_claudeDesktopTap = __esm({
7520
7538
  "cli/installer/claudeDesktopTap.ts"() {
7521
7539
  "use strict";
7522
7540
  CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
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";
7523
7544
  JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
7524
- ADDON_PY = `import os, json, threading, urllib.request, re
7545
+ ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64
7525
7546
  from mitmproxy import http
7526
7547
 
7527
7548
  CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7528
7549
  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", "")
7553
+ # Token file path \u2014 the SERVER_TOKEN rotates whenever the container is recreated,
7554
+ # so we re-read it from disk on a 401 instead of needing a tap restart.
7555
+ TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "")
7556
+ _token_lock = threading.Lock()
7557
+
7558
+ def _reload_token():
7559
+ global TOKEN
7560
+ if not TOKEN_FILE: return
7561
+ try:
7562
+ with open(TOKEN_FILE) as f:
7563
+ t = f.read().strip()
7564
+ if t:
7565
+ with _token_lock: TOKEN = t
7566
+ print("[synkro] auth token reloaded after 401 (container likely recreated)")
7567
+ except Exception:
7568
+ pass
7569
+
7570
+ def _post_json(url, payload_bytes, timeout):
7571
+ # POST JSON with auto-reauth: on a 401 (token rotated by a container recreate),
7572
+ # re-read the token file and retry ONCE. CWE-362: TOKEN is always snapshotted
7573
+ # under the lock before use, never read bare.
7574
+ for attempt in range(2):
7575
+ with _token_lock: tok = TOKEN
7576
+ try:
7577
+ req = urllib.request.Request(url, data=payload_bytes, headers={"Content-Type": "application/json", "Authorization": "Bearer " + tok})
7578
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
7579
+ return resp.read()
7580
+ except urllib.error.HTTPError as e:
7581
+ if e.code == 401 and attempt == 0:
7582
+ _reload_token(); continue
7583
+ raise
7584
+ return None
7529
7585
 
7530
7586
  # Governance signals parsed from the completion SSE (usage window + web-search
7531
7587
  # sources), keyed by conversation id. The completion POST always precedes the
@@ -7538,7 +7594,206 @@ _sse_lock = threading.Lock()
7538
7594
  _mcp_registry = {}
7539
7595
  _mcp_lock = threading.Lock()
7540
7596
 
7597
+ def _fmt_tool_name(raw):
7598
+ # Claude Desktop names connector tool_use blocks as "<ServerName><tool>" with
7599
+ # no delimiter (e.g. "Linearlist_issues"). Split it back to "Server:tool" using
7600
+ # the captured MCP registry so the dashboard can resolve the server favicon +
7601
+ # render a clean label. Exact server+tool match only \u2014 never a fuzzy guess.
7602
+ if not raw or ":" in raw: return raw
7603
+ try:
7604
+ with _mcp_lock:
7605
+ servers = list(_mcp_registry.values())
7606
+ for s in servers:
7607
+ sname = s.get("name", "")
7608
+ if not sname or not raw.startswith(sname): continue
7609
+ rest = raw[len(sname):]
7610
+ for t in s.get("tools", []):
7611
+ tn = t.get("name", "")
7612
+ if tn and rest == tn:
7613
+ return sname + ":" + tn
7614
+ except Exception:
7615
+ pass
7616
+ return raw
7617
+
7618
+ # \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
7619
+ # Whether to HOLD connector turns (buffer the live completion until graded).
7620
+ # Fetched once from the server: True only when a BLOCK-mode rule exists (+ not
7621
+ # silent). Audit-only orgs get False \u2192 stream + async audit, ZERO added latency.
7622
+ _dlp_hold = None
7623
+ _dlp_lock = threading.Lock()
7624
+ # Shown live in place of a held connector turn while the single grader (the tree
7625
+ # GET) runs; replaced by the real content (if clean) or the block notice.
7626
+ REVIEWING_NOTICE = "\\U0001F6E1\\uFE0F Synkro is reviewing this response for policy compliance\\u2026"
7627
+
7628
+ def _should_hold():
7629
+ global _dlp_hold
7630
+ with _dlp_lock:
7631
+ if _dlp_hold is not None:
7632
+ return _dlp_hold
7633
+ val = False
7634
+ if POLICY_URL:
7635
+ try:
7636
+ with urllib.request.urlopen(urllib.request.Request(POLICY_URL), timeout=4) as resp:
7637
+ val = bool(json.loads(resp.read().decode("utf-8", "replace")).get("hold"))
7638
+ except Exception:
7639
+ val = False
7640
+ with _dlp_lock:
7641
+ _dlp_hold = val
7642
+ return val
7643
+
7644
+ def _scan_turn_call(cid, msgs, connector):
7645
+ # Synchronous DLP grade. Returns the block reason (str) on BLOCK, else None.
7646
+ # Fail-OPEN \u2014 a scan error never blocks the turn.
7647
+ if not (SCAN_TURN_URL and TOKEN): return None
7648
+ try:
7649
+ vb = _post_json(SCAN_TURN_URL, json.dumps({"conversation_id": str(cid)[:80], "messages": msgs, "connector": connector}).encode(), 18)
7650
+ vd = json.loads((vb or b"{}").decode("utf-8", "replace"))
7651
+ if vd.get("block"):
7652
+ return str(vd.get("reason", "restricted data"))[:300]
7653
+ except Exception as e:
7654
+ print("[synkro] turn scan error (fail-open): " + str(e))
7655
+ return None
7656
+
7657
+ def _scan_sse_line(line, st):
7658
+ # Extract governance metadata (usage window + web-search sources/citations)
7659
+ # from one completion SSE line. Shared by the streaming + buffered paths.
7660
+ if st["usage"] is None:
7661
+ um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7662
+ if um:
7663
+ st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
7664
+ if len(st["sources"]) < 50:
7665
+ for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
7666
+ d = sm.group(1)
7667
+ if d and d not in st["seen"] and len(st["sources"]) < 50:
7668
+ st["seen"].add(d)
7669
+ st["sources"].append({"domain": d, "name": sm.group(2)})
7670
+ if "citation_start_delta" in line and line.startswith("data: ") and len(st["citations"]) < 50:
7671
+ try:
7672
+ cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
7673
+ url = cit.get("url", "")
7674
+ if url and url not in st["cseen"]:
7675
+ st["cseen"].add(url)
7676
+ md = cit.get("metadata") or {}
7677
+ st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
7678
+ except Exception:
7679
+ pass
7680
+
7681
+ def _new_scan_state():
7682
+ return {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7683
+
7684
+ def _extract_sse_text(body):
7685
+ # Pull all visible text out of a buffered completion SSE (assistant text +
7686
+ # tool_result content) for grading, and detect whether a tool/connector ran.
7687
+ parts = []
7688
+ tool = ("tool_use" in body) or ("tool_result" in body)
7689
+ total = 0
7690
+ for line in body.split("\\n"):
7691
+ if total > 2000000: break
7692
+ if not line.startswith("data: "): continue
7693
+ try: obj = json.loads(line[6:])
7694
+ except Exception: continue
7695
+ d = obj.get("delta")
7696
+ if isinstance(d, dict) and isinstance(d.get("text"), str):
7697
+ parts.append(d["text"]); total += len(d["text"])
7698
+ cb = obj.get("content_block")
7699
+ if isinstance(cb, dict):
7700
+ if isinstance(cb.get("text"), str): parts.append(cb["text"]); total += len(cb["text"])
7701
+ c = cb.get("content")
7702
+ if isinstance(c, str): parts.append(c); total += len(c)
7703
+ elif isinstance(c, list):
7704
+ for x in c:
7705
+ if isinstance(x, dict) and isinstance(x.get("text"), str): parts.append(x["text"]); total += len(x["text"])
7706
+ return ("".join(parts)[:2000000], tool)
7707
+
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.
7712
+ out = []
7713
+ injected = [False]
7714
+ for line in body.split("\\n"):
7715
+ if not line.startswith("data: "):
7716
+ out.append(line); continue
7717
+ try: obj = json.loads(line[6:])
7718
+ except Exception:
7719
+ out.append(line); continue
7720
+ changed = False
7721
+ d = obj.get("delta")
7722
+ if isinstance(d, dict) and isinstance(d.get("text"), str):
7723
+ d["text"] = (notice if not injected[0] else "")
7724
+ injected[0] = True; changed = True
7725
+ cb = obj.get("content_block")
7726
+ if isinstance(cb, dict):
7727
+ if isinstance(cb.get("text"), str) and cb["text"]:
7728
+ cb["text"] = ""; changed = True
7729
+ if cb.get("content"):
7730
+ cb["content"] = ""; changed = True
7731
+ out.append(("data: " + json.dumps(obj)) if changed else line)
7732
+ return "\\n".join(out)
7733
+
7541
7734
  class SynkroTap:
7735
+ def request(self, flow):
7736
+ # \u2500\u2500 Upload DLP gate \u2500\u2500 Scan file uploads BEFORE they reach claude.ai and
7737
+ # refuse a blocked one (the file never enters the model). SYNCHRONOUS: this
7738
+ # upload waits for the verdict. Fail-OPEN \u2014 a scan error never breaks an
7739
+ # upload. CWE-200: never print the filename/content (size/rule only).
7740
+ r = flow.request
7741
+ if not SCAN_URL or not TOKEN: return
7742
+ if "claude.ai" not in (r.pretty_host or ""): return
7743
+ if r.method != "POST": return
7744
+ ct = r.headers.get("content-type", "")
7745
+ path = r.path or ""
7746
+ is_upload = ("multipart/form-data" in ct) or bool(re.search(r"/(files|upload|convert)(/|$|\\?)", path))
7747
+ if not is_upload: return
7748
+ # CWE-400: bound everything. Oversized uploads scan on name/type only \u2014 we
7749
+ # never pull a multi-MB body into memory or forward it to the scanner.
7750
+ MAX_RAW = 8 * 1024 * 1024
7751
+ try: clen = int(r.headers.get("content-length", "0") or "0")
7752
+ except Exception: clen = 0
7753
+ if clen and clen > MAX_RAW:
7754
+ payload = {"file_name": "", "size": clen, "content_b64": ""}
7755
+ else:
7756
+ try: body = r.get_content() or b""
7757
+ except Exception: return
7758
+ if not body or len(body) > MAX_RAW: return
7759
+ fname = ""
7760
+ fm = re.search(rb'filename="([^"]*)"', body)
7761
+ if fm:
7762
+ try: fname = fm.group(1).decode("utf-8", "replace")[:300]
7763
+ except Exception: fname = ""
7764
+ # Extract the uploaded file's RAW bytes from the multipart body so the
7765
+ # grader Reads the real file (a PDF/image must be intact, not the
7766
+ # multipart envelope). Fall back to the whole body if no part is found.
7767
+ file_bytes = None
7768
+ bm = re.search(r"boundary=([^;]+)", ct)
7769
+ if bm and fm:
7770
+ boundary = ("--" + bm.group(1).strip().strip('"')).encode()
7771
+ hdr_end = body.find(b"\\r\\n\\r\\n", fm.start())
7772
+ if hdr_end != -1:
7773
+ s = hdr_end + 4
7774
+ e = body.find(b"\\r\\n" + boundary, s)
7775
+ if e != -1: file_bytes = body[s:e]
7776
+ if file_bytes is None: file_bytes = body
7777
+ try: b64 = base64.b64encode(file_bytes).decode("ascii")
7778
+ except Exception: return
7779
+ payload = {"file_name": fname, "size": len(file_bytes), "content_b64": b64}
7780
+ try:
7781
+ respb = _post_json(SCAN_URL, json.dumps(payload).encode(), 55)
7782
+ verdict = json.loads((respb or b"{}").decode("utf-8", "replace"))
7783
+ except Exception as e:
7784
+ print("[synkro] upload scan error (fail-open): " + str(e))
7785
+ return
7786
+ if verdict.get("block"):
7787
+ reason = str(verdict.get("reason", "Blocked by Synkro policy"))[:300]
7788
+ flow.response = http.Response.make(
7789
+ 403,
7790
+ json.dumps({"error": {"type": "blocked_by_synkro", "message": "Blocked by Synkro: " + reason}}).encode(),
7791
+ {"Content-Type": "application/json"},
7792
+ )
7793
+ print("[synkro] BLOCKED upload (size=" + str(payload.get("size", 0)) + " rule=" + str(verdict.get("ruleId", "")) + ")")
7794
+ else:
7795
+ print("[synkro] upload scanned -> allow (size=" + str(payload.get("size", 0)) + ")")
7796
+
7542
7797
  def responseheaders(self, flow):
7543
7798
  # Buffer the small conversation-tree GET (parsed for the dashboard).
7544
7799
  # For the completion POST, TAP the SSE chunk-by-chunk and pass each
@@ -7552,34 +7807,19 @@ class SynkroTap:
7552
7807
  if r.method == "POST" and "completion" in r.path:
7553
7808
  cm = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7554
7809
  cid = cm.group(1) if cm else ""
7555
- # Incremental parse: scan complete SSE lines for the small governance
7556
- # signals AS THEY STREAM and keep only the extracted results \u2014 never
7557
- # buffer the body. The carry holds at most one partial line (bounded),
7558
- # so there's NO cap on total stream length and no source is dropped.
7559
- st = {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7560
- def _scan(line, _st=st):
7561
- if _st["usage"] is None:
7562
- um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7563
- if um:
7564
- _st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
7565
- if len(_st["sources"]) < 50:
7566
- for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
7567
- d = sm.group(1)
7568
- if d and d not in _st["seen"] and len(_st["sources"]) < 50:
7569
- _st["seen"].add(d)
7570
- _st["sources"].append({"domain": d, "name": sm.group(2)})
7571
- # Citations: the specific pages the model cited (title + url +
7572
- # domain) \u2014 the real "tool result" for web search.
7573
- if "citation_start_delta" in line and line.startswith("data: ") and len(_st["citations"]) < 50:
7574
- try:
7575
- cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
7576
- url = cit.get("url", "")
7577
- if url and url not in _st["cseen"]:
7578
- _st["cseen"].add(url)
7579
- md = cit.get("metadata") or {}
7580
- _st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
7581
- except Exception:
7582
- pass
7810
+ with _mcp_lock:
7811
+ 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.
7817
+ if has_servers and _should_hold():
7818
+ flow.metadata["synkro_hold_cid"] = cid
7819
+ return
7820
+ # Streaming path (unchanged behaviour): tap chunk-by-chunk, extract
7821
+ # governance metadata only, never buffer the body. Audit stays async.
7822
+ st = _new_scan_state()
7583
7823
  def _tap(data, _st=st, _cid=cid):
7584
7824
  if data:
7585
7825
  try:
@@ -7589,9 +7829,9 @@ class SynkroTap:
7589
7829
  lines = txt.split("\\n")
7590
7830
  _st["carry"] = lines[-1][-200000:] # bound the carried partial line (CWE-400)
7591
7831
  for ln in lines[:-1]:
7592
- _scan(ln)
7832
+ _scan_sse_line(ln, _st)
7593
7833
  else: # end of stream \u2014 flush carry + publish
7594
- _scan(_st["carry"])
7834
+ _scan_sse_line(_st["carry"], _st)
7595
7835
  if (_st["usage"] or _st["sources"] or _st["citations"]) and _cid:
7596
7836
  with _sse_lock:
7597
7837
  _sse_meta[_cid] = {"usage": _st["usage"], "sources": _st["sources"], "citations": _st["citations"]}
@@ -7605,6 +7845,36 @@ class SynkroTap:
7605
7845
  def response(self, flow):
7606
7846
  r = flow.request
7607
7847
  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
7608
7878
  # MCP server registry from the bootstrap SSE stream.
7609
7879
  if "mcp" in r.path and "bootstrap" in r.path and flow.response:
7610
7880
  try:
@@ -7668,7 +7938,7 @@ class SynkroTap:
7668
7938
  elif bt == "thinking":
7669
7939
  thinking += b.get("thinking", "") or b.get("text", "")
7670
7940
  elif bt == "tool_use" or bt == "server_tool_use":
7671
- tool_calls.append({"name": b.get("name", ""), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
7941
+ tool_calls.append({"name": _fmt_tool_name(b.get("name", "")), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
7672
7942
  elif bt.endswith("_tool_result") or bt == "tool_result":
7673
7943
  rc = b.get("content", "")
7674
7944
  if isinstance(rc, list):
@@ -7690,7 +7960,7 @@ class SynkroTap:
7690
7960
  # web_search results are server-side (content:[]); we surface the
7691
7961
  # sources via sse_meta instead. Only record a non-empty result.
7692
7962
  if rc and str(rc).strip():
7693
- tool_results.append({"name": b.get("name", ""), "content": str(rc)})
7963
+ tool_results.append({"name": _fmt_tool_name(b.get("name", "")), "content": str(rc)})
7694
7964
  elif isinstance(blocks, str):
7695
7965
  text_parts.append(blocks)
7696
7966
  text = " ".join(p for p in text_parts if p).strip()
@@ -7721,23 +7991,92 @@ class SynkroTap:
7721
7991
  ntools += len(mm.get("tool_calls") or [])
7722
7992
  nsrc = len((sm or {}).get("sources") or [])
7723
7993
  print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
7994
+
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)
8021
+
8022
+ # Capture the REAL content for audit regardless of the block decision.
7724
8023
  def send(_d=data):
7725
8024
  try:
7726
- req = urllib.request.Request(CAPTURE_URL, data=json.dumps(_d).encode(), headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN})
7727
- urllib.request.urlopen(req, timeout=5)
8025
+ _post_json(CAPTURE_URL, json.dumps(_d).encode(), 5)
7728
8026
  except Exception as e:
7729
8027
  print("[synkro] capture error: " + str(e))
7730
8028
  threading.Thread(target=send, daemon=True).start()
7731
8029
 
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}]
8038
+ try:
8039
+ flow.response.text = json.dumps(convo)
8040
+ print("[synkro] BLOCKED turn view (reason=" + blocked_reason[:80] + ")")
8041
+ except Exception as e:
8042
+ print("[synkro] block scrub failed (fail-open): " + str(e))
8043
+
7732
8044
  addons = [SynkroTap()]
7733
8045
  `;
7734
8046
  MCP_PROXY_PY = `#!/usr/bin/env python3
7735
8047
  """Synkro MCP Proxy (local) \u2014 stdio passthrough that taps MCP tool calls."""
7736
- import sys, os, json, subprocess, threading, urllib.request, time
8048
+ import sys, os, json, subprocess, threading, urllib.request, urllib.error, time
7737
8049
 
7738
8050
  CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7739
8051
  TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7740
8052
  SERVER_NAME = os.environ.get("SYNKRO_MCP_SERVER", "unknown")
8053
+ # The SERVER_TOKEN rotates on container recreate; re-read it from disk on a 401.
8054
+ TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "") or os.path.expanduser("~/.synkro/.mcp-jwt")
8055
+ _token_lock = threading.Lock()
8056
+
8057
+ def _reload_token():
8058
+ global TOKEN
8059
+ try:
8060
+ with open(TOKEN_FILE) as f:
8061
+ t = f.read().strip()
8062
+ if t:
8063
+ with _token_lock: TOKEN = t
8064
+ except Exception:
8065
+ pass
8066
+
8067
+ def _post_json(url, payload_bytes, timeout):
8068
+ # CWE-362: TOKEN snapshotted under the lock. Auto-reauth retry once on 401.
8069
+ for attempt in range(2):
8070
+ with _token_lock: tok = TOKEN
8071
+ try:
8072
+ req = urllib.request.Request(url, data=payload_bytes, headers={"Content-Type": "application/json", "Authorization": "Bearer " + tok})
8073
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
8074
+ return resp.read()
8075
+ except urllib.error.HTTPError as e:
8076
+ if e.code == 401 and attempt == 0:
8077
+ _reload_token(); continue
8078
+ raise
8079
+ return None
7741
8080
 
7742
8081
  pending = {}
7743
8082
  pending_lock = threading.Lock()
@@ -7755,8 +8094,7 @@ def send_capture(tool_name, tool_args, tool_result, latency_ms):
7755
8094
  },
7756
8095
  "mcp": {"server": SERVER_NAME, "tool": tool_name, "latency_ms": latency_ms}
7757
8096
  }).encode()
7758
- req = urllib.request.Request(CAPTURE_URL, data=payload, headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN})
7759
- urllib.request.urlopen(req, timeout=5)
8097
+ _post_json(CAPTURE_URL, payload, 5)
7760
8098
  except Exception as e:
7761
8099
  print("[synkro-mcp] capture error: " + str(e), file=sys.stderr)
7762
8100
 
@@ -7883,7 +8221,7 @@ __export(macKeychain_exports, {
7883
8221
  writeCursorApiKey: () => writeCursorApiKey,
7884
8222
  writeRefreshAgent: () => writeRefreshAgent
7885
8223
  });
7886
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
8224
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
7887
8225
  import { homedir as homedir6, platform as platform2 } from "os";
7888
8226
  import { join as join6 } from "path";
7889
8227
  import { spawnSync } from "child_process";
@@ -7903,7 +8241,7 @@ function readKeychainCreds() {
7903
8241
  function exportKeychainCreds() {
7904
8242
  const blob = readKeychainCreds();
7905
8243
  if (!blob) return null;
7906
- mkdirSync6(CLAUDE_CREDS_DIR, { recursive: true });
8244
+ mkdirSync7(CLAUDE_CREDS_DIR, { recursive: true });
7907
8245
  chmodSync(CLAUDE_CREDS_DIR, 448);
7908
8246
  writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
7909
8247
  chmodSync(CLAUDE_CREDS_FILE, 384);
@@ -7919,7 +8257,7 @@ function cursorApiKeyConfigured() {
7919
8257
  function writeCursorApiKey(key) {
7920
8258
  const trimmed = key.trim();
7921
8259
  if (!trimmed) return;
7922
- mkdirSync6(CURSOR_CREDS_DIR, { recursive: true });
8260
+ mkdirSync7(CURSOR_CREDS_DIR, { recursive: true });
7923
8261
  chmodSync(CURSOR_CREDS_DIR, 448);
7924
8262
  writeFileSync7(CURSOR_API_KEY_FILE, trimmed, "utf-8");
7925
8263
  chmodSync(CURSOR_API_KEY_FILE, 384);
@@ -7958,7 +8296,7 @@ function writeRefreshAgent(synkroBinPath) {
7958
8296
  if (platform2() !== "darwin") {
7959
8297
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
7960
8298
  }
7961
- mkdirSync6(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
8299
+ mkdirSync7(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
7962
8300
  const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
7963
8301
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
7964
8302
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -8074,7 +8412,7 @@ __export(dockerInstall_exports, {
8074
8412
  waitForContainerReady: () => waitForContainerReady,
8075
8413
  waitForWorkersReady: () => waitForWorkersReady
8076
8414
  });
8077
- import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
8415
+ import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
8078
8416
  import { homedir as homedir7 } from "os";
8079
8417
  import { join as join7 } from "path";
8080
8418
  import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
@@ -8301,9 +8639,9 @@ async function dockerInstall(opts = {}) {
8301
8639
  const claudeWorkers = opts.claudeWorkers ?? opts.workersPerPool ?? 8;
8302
8640
  const cursorWorkers = opts.cursorWorkers ?? 0;
8303
8641
  const totalWorkers = claudeWorkers + cursorWorkers;
8304
- mkdirSync7(PGDATA_PATH, { recursive: true });
8305
- mkdirSync7(BACKUP_DIR, { recursive: true });
8306
- mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
8642
+ mkdirSync8(PGDATA_PATH, { recursive: true });
8643
+ mkdirSync8(BACKUP_DIR, { recursive: true });
8644
+ mkdirSync8(CLAUDE_HOST_STATE_DIR, { recursive: true });
8307
8645
  const hostClaudeJson = join7(homedir7(), ".claude.json");
8308
8646
  if (existsSync9(hostClaudeJson)) {
8309
8647
  copyFileSync(hostClaudeJson, CLAUDE_HOST_STATE_FILE);
@@ -8313,7 +8651,7 @@ async function dockerInstall(opts = {}) {
8313
8651
  `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
8314
8652
  );
8315
8653
  }
8316
- mkdirSync7(CURSOR_CREDS_DIR, { recursive: true });
8654
+ mkdirSync8(CURSOR_CREDS_DIR, { recursive: true });
8317
8655
  if (needsKeychainBridge()) {
8318
8656
  if (claudeWorkers > 0) {
8319
8657
  const path = exportKeychainCreds();
@@ -8336,7 +8674,7 @@ async function dockerInstall(opts = {}) {
8336
8674
  console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
8337
8675
  }
8338
8676
  } else {
8339
- mkdirSync7(join7(homedir7(), ".claude"), { recursive: true });
8677
+ mkdirSync8(join7(homedir7(), ".claude"), { recursive: true });
8340
8678
  }
8341
8679
  const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
8342
8680
  const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
@@ -8578,7 +8916,7 @@ async function dockerSafeStart() {
8578
8916
  }
8579
8917
  } else {
8580
8918
  console.log(" pgdata: no existing data \u2014 fresh start.");
8581
- mkdirSync7(PGDATA_PATH, { recursive: true });
8919
+ mkdirSync8(PGDATA_PATH, { recursive: true });
8582
8920
  }
8583
8921
  console.log(" Starting container...");
8584
8922
  const start = spawnSync2("docker", ["start", CONTAINER_NAME], {
@@ -9022,7 +9360,7 @@ __export(install_exports, {
9022
9360
  syncSkillFiles: () => syncSkillFiles,
9023
9361
  writeHookScripts: () => writeHookScripts
9024
9362
  });
9025
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync9, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
9363
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync9, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
9026
9364
  import { homedir as homedir9 } from "os";
9027
9365
  import { join as join9 } from "path";
9028
9366
  import { execSync as execSync6 } from "child_process";
@@ -9180,11 +9518,11 @@ coding patterns and the dashboard shows full history. (Y/n) `
9180
9518
  }
9181
9519
  }
9182
9520
  function ensureSynkroDir() {
9183
- mkdirSync8(SYNKRO_DIR4, { recursive: true });
9184
- mkdirSync8(HOOKS_DIR, { recursive: true });
9185
- mkdirSync8(BIN_DIR, { recursive: true });
9186
- mkdirSync8(OFFSETS_DIR, { recursive: true });
9187
- mkdirSync8(join9(SYNKRO_DIR4, "sessions"), { recursive: true });
9521
+ mkdirSync9(SYNKRO_DIR4, { recursive: true });
9522
+ mkdirSync9(HOOKS_DIR, { recursive: true });
9523
+ mkdirSync9(BIN_DIR, { recursive: true });
9524
+ mkdirSync9(OFFSETS_DIR, { recursive: true });
9525
+ mkdirSync9(join9(SYNKRO_DIR4, "sessions"), { recursive: true });
9188
9526
  }
9189
9527
  function writeHookScripts(mode = "stub") {
9190
9528
  const installExtractCorePath = join9(HOOKS_DIR, "installExtractCore.ts");
@@ -9207,6 +9545,7 @@ function writeHookScripts(mode = "stub") {
9207
9545
  const cursorAgentCapturePath = join9(HOOKS_DIR, "cursor-agent-capture.ts");
9208
9546
  const mcpStdioProxyPath = join9(HOOKS_DIR, "mcp-stdio-proxy.ts");
9209
9547
  const taskActivateIntentScriptPath = join9(HOOKS_DIR, "cc-task-activate-intent.ts");
9548
+ const mcpGateScriptPath = join9(HOOKS_DIR, "cc-mcp-gate.ts");
9210
9549
  if (mode === "stub") {
9211
9550
  const stubCommonPath = join9(HOOKS_DIR, "_synkro-stub-common.ts");
9212
9551
  const stubFiles = [
@@ -9224,6 +9563,7 @@ function writeHookScripts(mode = "stub") {
9224
9563
  [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
9225
9564
  [installScanScriptPath, STUB_INSTALL_SCAN_TS],
9226
9565
  [taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS],
9566
+ [mcpGateScriptPath, STUB_MCP_GATE_TS],
9227
9567
  [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
9228
9568
  [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
9229
9569
  [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
@@ -9256,7 +9596,8 @@ function writeHookScripts(mode = "stub") {
9256
9596
  cursorBashJudgeScript: cursorBashJudgePath,
9257
9597
  cursorEditCaptureScript: cursorEditCapturePath,
9258
9598
  cursorAgentCaptureScript: cursorAgentCapturePath,
9259
- taskActivateIntentScript: taskActivateIntentScriptPath
9599
+ taskActivateIntentScript: taskActivateIntentScriptPath,
9600
+ mcpGateScript: mcpGateScriptPath
9260
9601
  };
9261
9602
  }
9262
9603
  writeFileSync8(bashScriptPath, BASH_JUDGE_TS, "utf-8");
@@ -9279,6 +9620,7 @@ function writeHookScripts(mode = "stub") {
9279
9620
  writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
9280
9621
  writeFileSync8(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
9281
9622
  writeFileSync8(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
9623
+ writeFileSync8(mcpGateScriptPath, STUB_MCP_GATE_TS, "utf-8");
9282
9624
  chmodSync2(bashScriptPath, 493);
9283
9625
  chmodSync2(bashFollowupScriptPath, 493);
9284
9626
  chmodSync2(editPrecheckScriptPath, 493);
@@ -9299,6 +9641,7 @@ function writeHookScripts(mode = "stub") {
9299
9641
  chmodSync2(mcpStdioProxyPath, 493);
9300
9642
  chmodSync2(installExtractCorePath, 493);
9301
9643
  chmodSync2(taskActivateIntentScriptPath, 493);
9644
+ chmodSync2(mcpGateScriptPath, 493);
9302
9645
  try {
9303
9646
  unlinkSync4(join9(HOOKS_DIR, "_synkro-stub-common.ts"));
9304
9647
  } catch {
@@ -9319,7 +9662,8 @@ function writeHookScripts(mode = "stub") {
9319
9662
  cursorBashJudgeScript: cursorBashJudgePath,
9320
9663
  cursorEditCaptureScript: cursorEditCapturePath,
9321
9664
  cursorAgentCaptureScript: cursorAgentCapturePath,
9322
- taskActivateIntentScript: taskActivateIntentScriptPath
9665
+ taskActivateIntentScript: taskActivateIntentScriptPath,
9666
+ mcpGateScript: mcpGateScriptPath
9323
9667
  };
9324
9668
  }
9325
9669
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -9351,7 +9695,7 @@ function writeConfigEnv(opts) {
9351
9695
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
9352
9696
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
9353
9697
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
9354
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.55")}`
9698
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.57")}`
9355
9699
  ];
9356
9700
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
9357
9701
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -9630,6 +9974,7 @@ async function installCommand(opts = {}) {
9630
9974
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
9631
9975
  installScanScriptPath: scripts.installScanScript,
9632
9976
  taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
9977
+ mcpGateScriptPath: scripts.mcpGateScript,
9633
9978
  skipTranscriptSync: !transcriptConsent
9634
9979
  });
9635
9980
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -9649,7 +9994,8 @@ async function installCommand(opts = {}) {
9649
9994
  sessionStartScriptPath: scripts.sessionStartScript,
9650
9995
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
9651
9996
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
9652
- installScanScriptPath: scripts.installScanScript
9997
+ installScanScriptPath: scripts.installScanScript,
9998
+ mcpGateScriptPath: scripts.mcpGateScript
9653
9999
  });
9654
10000
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
9655
10001
  }
@@ -10101,7 +10447,8 @@ function reconcileHarness() {
10101
10447
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
10102
10448
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
10103
10449
  installScanScriptPath: scripts.installScanScript,
10104
- taskActivateIntentScriptPath: scripts.taskActivateIntentScript
10450
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
10451
+ mcpGateScriptPath: scripts.mcpGateScript
10105
10452
  });
10106
10453
  console.log(" \u2713 Claude Code hooks registered");
10107
10454
  try {
@@ -10132,7 +10479,8 @@ function reconcileHarness() {
10132
10479
  sessionStartScriptPath: scripts.sessionStartScript,
10133
10480
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
10134
10481
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
10135
- installScanScriptPath: scripts.installScanScript
10482
+ installScanScriptPath: scripts.installScanScript,
10483
+ mcpGateScriptPath: scripts.mcpGateScript
10136
10484
  });
10137
10485
  console.log(" \u2713 Cursor hooks registered");
10138
10486
  try {
@@ -10645,14 +10993,14 @@ rl.on('line', async (line) => {
10645
10993
  });
10646
10994
 
10647
10995
  // cli/local-cc/install.ts
10648
- import { existsSync as existsSync12, mkdirSync as mkdirSync9, writeFileSync as writeFileSync9, readFileSync as readFileSync10, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
10996
+ import { existsSync as existsSync12, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, readFileSync as readFileSync10, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
10649
10997
  import { join as join10 } from "path";
10650
10998
  import { homedir as homedir10 } from "os";
10651
10999
  import { spawnSync as spawnSync4 } from "child_process";
10652
11000
  function writePluginFiles() {
10653
11001
  for (const c of CHANNELS) {
10654
- mkdirSync9(c.sessionDir, { recursive: true });
10655
- mkdirSync9(c.pluginSettingsDir, { recursive: true });
11002
+ mkdirSync10(c.sessionDir, { recursive: true });
11003
+ mkdirSync10(c.pluginSettingsDir, { recursive: true });
10656
11004
  writeFileSync9(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
10657
11005
  writeFileSync9(
10658
11006
  c.pluginSettingsPath,
@@ -11254,7 +11602,7 @@ var init_disconnect = __esm({
11254
11602
  });
11255
11603
 
11256
11604
  // cli/local-cc/turnLog.ts
11257
- import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
11605
+ import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync11, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
11258
11606
  import { dirname as dirname6, join as join12 } from "path";
11259
11607
  import { homedir as homedir12 } from "os";
11260
11608
  function truncate(s, max = PREVIEW_MAX) {
@@ -11276,7 +11624,7 @@ function extractSeverity(result) {
11276
11624
  }
11277
11625
  function appendTurn(args2) {
11278
11626
  try {
11279
- mkdirSync10(dirname6(TURN_LOG_PATH), { recursive: true });
11627
+ mkdirSync11(dirname6(TURN_LOG_PATH), { recursive: true });
11280
11628
  const entry = {
11281
11629
  ts: new Date(args2.startedAt).toISOString(),
11282
11630
  role: args2.role,
@@ -11312,7 +11660,7 @@ function readRecentTurns(n = 20) {
11312
11660
  }
11313
11661
  function followTurns(onEntry) {
11314
11662
  try {
11315
- mkdirSync10(dirname6(TURN_LOG_PATH), { recursive: true });
11663
+ mkdirSync11(dirname6(TURN_LOG_PATH), { recursive: true });
11316
11664
  if (!existsSync14(TURN_LOG_PATH)) {
11317
11665
  appendFileSync(TURN_LOG_PATH, "", "utf-8");
11318
11666
  }
@@ -12605,7 +12953,7 @@ var args = process.argv.slice(2);
12605
12953
  var cmd = args[0] || "";
12606
12954
  var subArgs = args.slice(1);
12607
12955
  function printVersion() {
12608
- console.log("1.6.55");
12956
+ console.log("1.6.57");
12609
12957
  }
12610
12958
  function printHelp2() {
12611
12959
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents